From db2e61b2f48c7dc686afa1d997fec43734281a32 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 21:36:56 -0400 Subject: [PATCH 1/5] feat(db): store listening position, podcasts and episodes --- src/lib/db.js | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/lib/db.js b/src/lib/db.js index 7c9bed3..bd2045a 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -36,6 +36,14 @@ db.version(3).stores({ lyrics: 'id', // { id: trackId, synced: [{time,text}]|null, plain: string|null, fetchedAt } }) +// v4: podcast subscriptions and their episodes. Episodes live apart from +// `tracks` so a show's back catalog doesn't flood the music library. A +// downloaded episode keeps its bytes in `audioBlobs` under the episode id. +db.version(4).stores({ + podcasts: 'id, title, subscribedAt', // id = feed URL + episodes: 'id, podcastId, pubDate', +}) + // Fields the download engine "owns" — safe to overwrite on catalog re-import. const CATALOG_FIELDS = ['title', 'artist', 'duration', 'thumbnailUrl', 'filePath', 'src'] @@ -200,3 +208,67 @@ export async function removeFromPlaylist(id, trackId) { pl.trackIds = pl.trackIds.filter((t) => t !== trackId) }) } + +// ── Listening position (podcast mode) ─────────────────────────────────────── +// Long-form items remember where you stopped. A track or an episode can be +// playing, so pick the table by the item's kind. +const tableFor = (item) => (item.kind === 'episode' ? db.episodes : db.tracks) + +export async function getPosition(item) { + const row = await tableFor(item).get(item.id) + return row?.position || 0 +} + +export async function savePosition(item, position) { + await tableFor(item).update(item.id, { position, lastPlayedAt: Date.now() }) +} + +export async function setPlayed(item, played) { + await tableFor(item).update(item.id, { played: played ? 1 : 0, position: 0 }) +} + +// ── Podcasts ──────────────────────────────────────────────────────────────── +// Feed fields to refresh on every fetch; everything else on an episode row +// (position, played, downloaded) is user state and survives a refresh. +const EPISODE_FEED_FIELDS = ['title', 'audioUrl', 'duration', 'pubDate', 'image', 'summary'] + +export async function savePodcast(show, episodes) { + await db.transaction('rw', db.podcasts, db.episodes, async () => { + const existing = await db.podcasts.get(show.id) + await db.podcasts.put({ subscribedAt: Date.now(), ...existing, ...show, refreshedAt: Date.now() }) + for (const ep of episodes) { + const old = await db.episodes.get(ep.id) + if (old) { + const patch = {} + for (const f of EPISODE_FEED_FIELDS) if (ep[f] !== undefined) patch[f] = ep[f] + await db.episodes.update(ep.id, patch) + } else { + await db.episodes.add({ position: 0, played: 0, downloaded: 0, lastPlayedAt: null, ...ep }) + } + } + }) +} + +/** Drop a show, its episodes, and any downloaded audio. */ +export async function unsubscribePodcast(podcastId) { + await db.transaction('rw', db.podcasts, db.episodes, db.audioBlobs, async () => { + const ids = await db.episodes.where('podcastId').equals(podcastId).primaryKeys() + await db.audioBlobs.bulkDelete(ids) + await db.episodes.bulkDelete(ids) + await db.podcasts.delete(podcastId) + }) +} + +export async function saveEpisodeAudio(episodeId, blob) { + await db.transaction('rw', db.episodes, db.audioBlobs, async () => { + await db.audioBlobs.put({ id: episodeId, blob }) + await db.episodes.update(episodeId, { downloaded: 1 }) + }) +} + +export async function removeEpisodeAudio(episodeId) { + await db.transaction('rw', db.episodes, db.audioBlobs, async () => { + await db.audioBlobs.delete(episodeId) + await db.episodes.update(episodeId, { downloaded: 0 }) + }) +} From 39fc98466e30d624256d03b1eedd3b31df0361be Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 21:36:56 -0400 Subject: [PATCH 2/5] feat(podcasts): iTunes search, RSS parsing and episode downloads --- src/lib/podcasts.js | 244 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 src/lib/podcasts.js diff --git a/src/lib/podcasts.js b/src/lib/podcasts.js new file mode 100644 index 0000000..ba4b4b1 --- /dev/null +++ b/src/lib/podcasts.js @@ -0,0 +1,244 @@ +import { useSyncExternalStore } from 'react' +import { db, savePodcast, saveEpisodeAudio } from './db' + +// ── Podcasts: search, RSS, downloads ──────────────────────────────────────── +// Search goes through Apple's public iTunes Search API (CORS-enabled, no key). +// Most feed hosts also send CORS headers, so feeds are fetched directly; the +// few that don't fall back to a public read-only proxy. + +const PROXY = 'https://api.allorigins.win/raw?url=' +const MAX_EPISODES = 200 // per show; older back catalog isn't worth the storage +const STALE_MS = 60 * 60 * 1000 + +/** Anything this long is treated as long-form (resume, speed, skip buttons). */ +export const LONG_FORM_SEC = 20 * 60 +export const isLongForm = (item) => + !!item && (item.kind === 'episode' || (item.duration || 0) >= LONG_FORM_SEC) + +export async function searchPodcasts(term) { + const url = `https://itunes.apple.com/search?media=podcast&entity=podcast&limit=20&term=${encodeURIComponent(term)}` + const res = await fetch(url) + if (!res.ok) throw new Error(`search-${res.status}`) + const { results = [] } = await res.json() + return results + .filter((r) => r.feedUrl) + .map((r) => ({ + id: httpsUrl(r.feedUrl), + title: r.collectionName, + author: r.artistName, + image: r.artworkUrl600 || r.artworkUrl100 || null, + })) +} + +/** Fetch, parse and store a feed. Returns the show. Used to subscribe and refresh. */ +export async function subscribe(feedUrl) { + const url = httpsUrl(feedUrl.trim()) + const xml = await fetchText(url) + const { show, episodes } = parseFeed(xml, url) + await savePodcast(show, episodes) + return show +} + +/** Refresh shows not fetched in the last hour. Failures are skipped (offline, feed down). */ +export async function refreshStale() { + const shows = await db.podcasts.toArray() + for (const s of shows) { + if (Date.now() - (s.refreshedAt || 0) < STALE_MS) continue + await subscribe(s.id).catch(() => {}) + } +} + +// Feeds are fetched from the page, and a mixed-content http:// request would +// be blocked outright, so upgrade first. Nearly every host serves both. +function httpsUrl(url) { + return url.replace(/^http:\/\//i, 'https://') +} + +async function fetchText(url) { + try { + const res = await fetch(url) + if (res.ok) return await res.text() + } catch { + // CORS or network failure; try the proxy below + } + const res = await fetch(PROXY + encodeURIComponent(url)) + if (!res.ok) throw new Error(`feed-${res.status}`) + return res.text() +} + +// ── RSS parsing ───────────────────────────────────────────────────────────── + +export function parseFeed(xmlText, feedUrl) { + const doc = new DOMParser().parseFromString(xmlText, 'application/xml') + const channel = doc.querySelector('channel') + if (!channel || doc.querySelector('parsererror')) throw new Error('not-a-feed') + + const show = { + id: feedUrl, + title: text(channel, 'title') || 'Untitled podcast', + author: text(channel, 'itunes:author') || '', + image: attr(channel, 'itunes:image', 'href') || text(child(channel, 'image'), 'url') || null, + } + + const episodes = [] + for (const item of channel.getElementsByTagName('item')) { + const enclosure = item.getElementsByTagName('enclosure')[0] + const audioUrl = enclosure?.getAttribute('url') + if (!audioUrl) continue // text-only posts + const guid = text(item, 'guid') || audioUrl + const pub = Date.parse(text(item, 'pubDate')) + episodes.push({ + id: `ep-${hash(feedUrl)}${hash(guid)}`, + podcastId: feedUrl, + title: text(item, 'title') || 'Untitled episode', + audioUrl: httpsUrl(audioUrl), + duration: parseDuration(text(item, 'itunes:duration')), + pubDate: Number.isFinite(pub) ? pub : 0, + image: attr(item, 'itunes:image', 'href') || null, + summary: plain(text(item, 'itunes:summary') || text(item, 'description')).slice(0, 400), + }) + } + episodes.sort((a, b) => b.pubDate - a.pubDate) + return { show, episodes: episodes.slice(0, MAX_EPISODES) } +} + +// Direct children only, so an item's never picks up a nested one. +function child(el, tag) { + if (!el) return null + for (const c of el.children) if (c.tagName === tag) return c + return null +} +const text = (el, tag) => child(el, tag)?.textContent.trim() || '' +const attr = (el, tag, name) => child(el, tag)?.getAttribute(name) || '' + +function plain(html) { + if (!html) return '' + return new DOMParser().parseFromString(html, 'text/html').body.textContent.replace(/\s+/g, ' ').trim() +} + +/** "1:02:03" | "62:03" | "3723" → seconds. */ +export function parseDuration(s) { + if (!s) return 0 + const parts = s.split(':').map(Number) + if (parts.some((n) => Number.isNaN(n))) return 0 + return Math.round(parts.reduce((acc, n) => acc * 60 + n, 0)) +} + +// FNV-1a, 8 hex chars. Only needs to be stable, not secure. +function hash(str) { + let h = 0x811c9dc5 + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return (h >>> 0).toString(16).padStart(8, '0') +} + +/** Shape an episode like a track so the player can take it. */ +export function toPlayable(ep, show) { + return { + id: ep.id, + kind: 'episode', + podcastId: ep.podcastId, + title: ep.title, + artist: show?.title || '', + duration: ep.duration, + thumbnailUrl: ep.image || show?.image || null, + src: ep.audioUrl, + } +} + +// ── Downloads ─────────────────────────────────────────────────────────────── +// In-flight progress lives outside React so it survives switching tabs. +// progress: { [episodeId]: 0..1, or -1 while the size is unknown } + +let progress = {} +const listeners = new Set() +const emit = () => listeners.forEach((l) => l()) +const setProgress = (id, v) => { + progress = { ...progress } + if (v === undefined) delete progress[id] + else progress[id] = v + emit() +} + +const subscribeDownloads = (l) => { + listeners.add(l) + return () => listeners.delete(l) +} + +export function useDownloads() { + return useSyncExternalStore(subscribeDownloads, () => progress) +} + +// Episode URLs are often wrapped in ad-measurement redirects +// (dts.podtrac.com/redirect.mp3/pdst.fm/e/…/real-host/file.mp3), and one hop +// without CORS headers fails the whole fetch. Each prefix just forwards to +// the rest of the path, so peeling them off reaches the real host directly. +const TRACKING_PREFIXES = [ + /^(dts\.|www\.)?podtrac\.com\/(pts\/)?redirect\.(mp3|m4a|aac)\//, + /^pdst\.fm\/e\//, + /^pfx\.vpixl\.com\/[^/]+\//, + /^(pscrb\.fm|verifi\.podscribe\.com)\/rss\/p\//, + /^(chrt\.fm|chtbl\.com)\/track\/[^/]+\//, + /^clrtpod\.com\/m\//, + /^mgln\.ai\/e\/[^/]+\//, + /^arttrk\.com\/p\/[^/]+\//, + /^op3\.dev\/e(,[^/]*)?\//, + /^prfx\.byspotify\.com\/e\//, + /^pdcn\.co\/e\//, + /^tracking\.swap\.fm\/track\/[^/]+\//, +] + +export function unwrapTracking(url) { + let rest = url.replace(/^https?:\/\//i, '') + for (let changed = true; changed;) { + changed = false + for (const re of TRACKING_PREFIXES) { + if (re.test(rest)) { + rest = rest.replace(re, '') + changed = true + } + } + } + return `https://${rest}` +} + +async function fetchAudio(url) { + try { + const res = await fetch(url) + if (res.ok) return res + } catch { + // a redirect hop without CORS; retry past the trackers below + } + const direct = unwrapTracking(url) + // Still throws for hosts that send no CORS headers at all. Those episodes + // stream fine; they just can't be saved for offline. + if (direct === url) throw new Error('download-blocked') + const res = await fetch(direct) + if (!res.ok) throw new Error(`download-${res.status}`) + return res +} + +export async function downloadEpisode(ep) { + if (progress[ep.id] !== undefined) return + setProgress(ep.id, -1) + try { + const res = await fetchAudio(ep.audioUrl) + const total = Number(res.headers.get('content-length')) || 0 + const reader = res.body.getReader() + const chunks = [] + let got = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + got += value.length + if (total) setProgress(ep.id, got / total) + } + const type = res.headers.get('content-type') || 'audio/mpeg' + await saveEpisodeAudio(ep.id, new Blob(chunks, { type })) + } finally { + setProgress(ep.id, undefined) + } +} From cc3fee59777227387d8103269a5298997c6ff92e Mon Sep 17 00:00:00 2001 From: Enes Yilmaz <enesyilmaz5157@gmail.com> Date: Fri, 25 Sep 2026 21:36:56 -0400 Subject: [PATCH 3/5] feat(player): podcast mode with resume, speed and 15/30 skip --- src/components/Player.jsx | 61 ++++++++++---- src/components/TrackRow.jsx | 7 +- src/lib/format.js | 6 ++ src/state/PlayerProvider.jsx | 156 ++++++++++++++++++++++++++--------- 4 files changed, 173 insertions(+), 57 deletions(-) diff --git a/src/components/Player.jsx b/src/components/Player.jsx index 6bf58e5..7298a67 100644 --- a/src/components/Player.jsx +++ b/src/components/Player.jsx @@ -31,7 +31,7 @@ export default function Player() { <span className="mini__meta"> <span className="mini__title">{p.current.title}</span> <span className="mini__artist"> - {p.missing ? 'Audio unavailable — re-import' : p.current.artist} + {p.missing ? missingShort(p.current) : p.current.artist} </span> </span> <span className="mini__controls" onClick={(e) => e.stopPropagation()}> @@ -97,9 +97,7 @@ function NowPlaying({ p, onClose }) { </div> {p.missing ? ( - <p className="now__missing" role="status"> - Audio unavailable — the file for this track is missing. Re-import it to play. - </p> + <p className="now__missing" role="status">{missingLong(p.current)}</p> ) : ( <div className="scrub"> <input @@ -119,21 +117,35 @@ function NowPlaying({ p, onClose }) { )} <div className="transport"> - <button className="iconbtn" onClick={p.prev} aria-label="Previous"><PrevIcon /></button> + {p.longForm ? ( + <button className="iconbtn skipbtn" onClick={() => p.skip(-15)} aria-label="Back 15 seconds"><SkipIcon back n={15} /></button> + ) : ( + <button className="iconbtn" onClick={p.prev} aria-label="Previous"><PrevIcon /></button> + )} <button className="playbtn" onClick={p.toggle} aria-label={p.isPlaying ? 'Pause' : 'Play'} disabled={p.missing}> {p.isPlaying ? <PauseIcon big /> : <PlayIcon big />} </button> - <button className="iconbtn" onClick={p.next} aria-label="Next"><NextIcon /></button> + {p.longForm ? ( + <button className="iconbtn skipbtn" onClick={() => p.skip(30)} aria-label="Forward 30 seconds"><SkipIcon n={30} /></button> + ) : ( + <button className="iconbtn" onClick={p.next} aria-label="Next"><NextIcon /></button> + )} </div> - <button - className={`loopbtn loopbtn--${p.loopMode}`} - onClick={p.cycleLoop} - aria-label={loopLabel} - > - <RepeatIcon /> <span>{loopLabel}</span> - {p.loopMode === 'one' && <em className="loopbtn__one">1</em>} - </button> + {p.longForm ? ( + <button className="loopbtn speedbtn" onClick={p.cycleSpeed} aria-label={`Playback speed ${p.speed}x`}> + <span>{p.speed}×</span> + </button> + ) : ( + <button + className={`loopbtn loopbtn--${p.loopMode}`} + onClick={p.cycleLoop} + aria-label={loopLabel} + > + <RepeatIcon /> <span>{loopLabel}</span> + {p.loopMode === 'one' && <em className="loopbtn__one">1</em>} + </button> + )} </div> ) } @@ -189,6 +201,16 @@ 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' +} +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 <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 6h11M4 12h9M4 18h7" /><path d="M16 17V9l4-1.5V15" /><circle cx="14.5" cy="17" r="1.6" fill="currentColor" stroke="none" /><circle cx="18.5" cy="15" r="1.6" fill="currentColor" stroke="none" /></svg> } @@ -199,3 +221,14 @@ function PrevIcon() { return <svg viewBox="0 0 24 24" width="22" height="22" {.. function RepeatIcon() { return <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 2l4 4-4 4" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><path d="M7 22l-4-4 4-4" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /></svg> } function ChevronDown() { return <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6" /></svg> } function StarIcon({ filled }) { return <svg viewBox="0 0 24 24" width="24" height="24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinejoin="round"><path d="M12 3.5l2.7 5.5 6 .9-4.3 4.2 1 6-5.4-2.8L6.6 20l1-6L3.3 9.9l6-.9z" /></svg> } +function SkipIcon({ back, n }) { + return ( + <svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> + <g transform={back ? undefined : 'translate(24 0) scale(-1 1)'}> + <path d="M4 12a8 8 0 1 0 2.3-5.7" /> + <path d="M4 3v4h4" /> + </g> + <text x="12" y="15.6" textAnchor="middle" fontSize="7.5" fontWeight="700" fill="currentColor" stroke="none">{n}</text> + </svg> + ) +} diff --git a/src/components/TrackRow.jsx b/src/components/TrackRow.jsx index e68388a..120d3de 100644 --- a/src/components/TrackRow.jsx +++ b/src/components/TrackRow.jsx @@ -1,5 +1,6 @@ import Artwork from './Artwork' -import { formatTime } from '../lib/format' +import { formatTime, timeLeft } from '../lib/format' +import { isLongForm } from '../lib/podcasts' import { toggleStar } from '../lib/db' import { usePlayer } from '../state/PlayerProvider' import { useUI } from '../state/UIProvider' @@ -22,7 +23,9 @@ export default function TrackRow({ track, list }) { {isCurrent && isPlaying && <EqBars />} </button> - <span className="row__dur">{formatTime(track.duration)}</span> + <span className="row__dur"> + {isLongForm(track) && track.position > 0 ? timeLeft(track.duration, track.position) : formatTime(track.duration)} + </span> <button className={`iconbtn ${track.starred ? 'iconbtn--star-on' : ''}`} diff --git a/src/lib/format.js b/src/lib/format.js index 4555e9d..6f6b08d 100644 --- a/src/lib/format.js +++ b/src/lib/format.js @@ -18,3 +18,9 @@ export function formatTime(sec) { if (h) return `${h}:${m.toString().padStart(2, '0')}:${r}` return `${m}:${r}` } + +/** Remaining listening time, rounded to minutes: "42m left", "1h 5m left". */ +export function timeLeft(duration, position) { + const m = Math.max(1, Math.round(((duration || 0) - (position || 0)) / 60)) + return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m left` : `${m}m left` +} diff --git a/src/state/PlayerProvider.jsx b/src/state/PlayerProvider.jsx index 20db91c..c9e27d4 100644 --- a/src/state/PlayerProvider.jsx +++ b/src/state/PlayerProvider.jsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react' -import { bumpPlayCount, getAudioBlob } from '../lib/db' +import { bumpPlayCount, getAudioBlob, getPosition, savePosition, setPlayed } from '../lib/db' +import { isLongForm } from '../lib/podcasts' const PlayerContext = createContext(null) export const usePlayer = () => useContext(PlayerContext) @@ -8,6 +9,20 @@ 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] +const SKIP_BACK = 15 +const SKIP_FORWARD = 30 + +function loadSpeed() { + try { + const v = Number(localStorage.getItem('melody.speed')) + return SPEEDS.includes(v) ? v : 1 + } catch { + return 1 + } +} + // Every MediaSession action we ever register — used to tear them all down on // cleanup / when nothing is playing, so the lock screen never keeps stale // handlers bound to a previous track's closures. @@ -19,7 +34,8 @@ const MEDIA_ACTIONS = [ export function PlayerProvider({ children }) { const audioRef = useRef(null) const countedRef = useRef(false) // so each play only bumps playCount once - const loadedIdRef = useRef(null) // the track id the <audio> element is loaded with + 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 objectUrlRef = useRef(null) // current blob: URL, revoked when track changes const [missing, setMissing] = useState(false) // audio bytes not found @@ -34,8 +50,22 @@ export function PlayerProvider({ children }) { // loop-all wrap, without the setIndex(-1) bounce that made `current` briefly // null (which flashed the Now Playing screen closed). const [playToken, setPlayToken] = useState(0) + const [speed, setSpeed] = useState(loadSpeed) + 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. + const applyRate = useCallback(() => { + const audio = audioRef.current + if (!audio) return + const rate = isLongForm(loadedRef.current) ? speedRef.current : 1 + audio.defaultPlaybackRate = rate + audio.playbackRate = rate + }, []) // ── Core: load + play a queue starting at a given index ── const playQueue = useCallback((tracks, startIndex = 0) => { @@ -83,7 +113,7 @@ export function PlayerProvider({ children }) { // Blank the loaded-id until the element actually switches to this track, so // a timeupdate from the still-playing PREVIOUS track (during the async blob // read) can't be credited to it a second time. - loadedIdRef.current = null + loadedRef.current = null setMissing(false) // Reset the scrubber immediately so it doesn't show the previous track's // position/length until the new metadata arrives. @@ -98,34 +128,39 @@ export function PlayerProvider({ children }) { } ;(async () => { - let url = current.src - if (!url) { - const blob = await getAudioBlob(current.id) - if (!blob) { - // Bytes are gone (cleared storage / failed import). Stop the element - // so it doesn't keep playing the PREVIOUS track under a now-missing - // `current`, and reset state so the UI can show an honest message. - if (!cancelled) { - audio.pause() - audio.removeAttribute('src') - audio.load() - revokePrev() // release the previous track's blob URL too - setIsPlaying(false) - setMissing(true) - } - return + // Imported tracks only exist as a Blob. Episodes stream from their URL + // unless they've been downloaded, in which case the Blob wins (offline). + let blob = null + if (!current.src || current.kind === 'episode') blob = await getAudioBlob(current.id) + if (!blob && !current.src) { + // Bytes are gone (cleared storage / failed import). Stop the element + // so it doesn't keep playing the PREVIOUS track under a now-missing + // `current`, and reset state so the UI can show an honest message. + if (!cancelled) { + audio.pause() + audio.removeAttribute('src') + audio.load() + revokePrev() // release the previous track's blob URL too + setIsPlaying(false) + setMissing(true) } - if (cancelled) return - url = URL.createObjectURL(blob) - } - if (cancelled) { - if (!current.src) URL.revokeObjectURL(url) return } + const resumeAt = isLongForm(current) ? await getPosition(current).catch(() => 0) : 0 + if (cancelled) return + const url = blob ? URL.createObjectURL(blob) : current.src revokePrev() - if (!current.src) objectUrlRef.current = url + if (blob) objectUrlRef.current = url audio.src = url - loadedIdRef.current = current.id // element is now this track → safe to count + loadedRef.current = current // element is now this track → safe to count + lastSavedRef.current = resumeAt + applyRate() + if (resumeAt > 5) { + // Pick up where you left off, unless that was the last few seconds. + audio.addEventListener('loadedmetadata', () => { + if (!audio.duration || resumeAt < audio.duration - 15) audio.currentTime = resumeAt + }, { once: true }) + } audio.loop = loopMode === 'one' audio.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false)) })() @@ -134,6 +169,11 @@ export function PlayerProvider({ children }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [index, current?.id, playToken]) + useEffect(() => { + applyRate() + try { localStorage.setItem('melody.speed', String(speed)) } catch { /* private mode */ } + }, [speed, applyRate]) + // Keep the native loop flag in sync when the mode changes mid-track. useEffect(() => { if (audioRef.current) audioRef.current.loop = loopMode === 'one' @@ -204,6 +244,28 @@ export function PlayerProvider({ children }) { if (audioRef.current) audioRef.current.currentTime = t }, []) + const skip = useCallback((sec) => { + const a = audioRef.current + if (!a) return + const t = (a.currentTime || 0) + sec + 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]) + }, []) + + // Write the listening position for long-form items. Skipped in the last + // stretch so finishing an episode doesn't leave it "resume at 59:58", and in + // the first seconds, which is where a fresh load sits before the resume seek. + const persistPosition = (a) => { + const item = loadedRef.current + if (!isLongForm(item) || !a.duration) return + if (a.currentTime < 5 || a.currentTime > a.duration - 15) return + lastSavedRef.current = a.currentTime + savePosition(item, a.currentTime).catch(() => {}) + } + const cycleLoop = useCallback(() => { setLoopMode((m) => LOOP_MODES[(LOOP_MODES.indexOf(m) + 1) % LOOP_MODES.length]) }, []) @@ -216,10 +278,11 @@ export function PlayerProvider({ children }) { // through a short track. Doing it here (not on loadedmetadata) means // skipping past tracks no longer inflates playCount, and we credit the // track the element is actually loaded with, not a since-changed `current`. - if (!countedRef.current && loadedIdRef.current && a.currentTime >= Math.min(5, (a.duration || 10) * 0.5)) { + if (!countedRef.current && loadedRef.current && a.currentTime >= Math.min(5, (a.duration || 10) * 0.5)) { countedRef.current = true - bumpPlayCount(loadedIdRef.current).catch(() => {}) + bumpPlayCount(loadedRef.current.id).catch(() => {}) } + if (Math.abs(a.currentTime - lastSavedRef.current) >= 5) persistPosition(a) // Feed the lock-screen scrubber on iOS. if ('mediaSession' in navigator && navigator.mediaSession.setPositionState && a.duration && Number.isFinite(a.duration)) { try { @@ -235,8 +298,10 @@ export function PlayerProvider({ children }) { } const onLoadedMeta = (e) => { setDuration(e.target.duration || 0) + applyRate() } const onEnded = () => { + if (isLongForm(loadedRef.current)) setPlayed(loadedRef.current, true).catch(() => {}) // loop === 'one' is handled by audio.loop (no 'ended' fires). if (index + 1 < queue.length) { setIndex((i) => i + 1) @@ -283,25 +348,24 @@ export function PlayerProvider({ children }) { } set('play', play) set('pause', pause) - set('previoustrack', prev) - set('nexttrack', next) + // Long-form gets skip buttons on the lock screen instead of prev/next: + // iOS only shows them when the track actions are left unset. + if (!longForm) { + set('previoustrack', prev) + set('nexttrack', next) + } set('stop', () => { pause() if (audioRef.current) audioRef.current.currentTime = 0 }) - set('seekbackward', (d) => seek(Math.max(0, (audioRef.current?.currentTime || 0) - (d.seekOffset || 10)))) - set('seekforward', (d) => { - const a = audioRef.current - if (!a) return - const target = (a.currentTime || 0) + (d.seekOffset || 10) - seek(a.duration ? Math.min(a.duration, target) : target) - }) + set('seekbackward', (d) => skip(-(d.seekOffset || (longForm ? SKIP_BACK : 10)))) + set('seekforward', (d) => skip(d.seekOffset || (longForm ? SKIP_FORWARD : 10))) set('seekto', (d) => { if (d.seekTime != null) seek(d.seekTime) }) // 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, play, pause, prev, next, seek]) + }, [current, 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 @@ -314,8 +378,8 @@ export function PlayerProvider({ children }) { }, [isPlaying, current]) const value = { - current, queue, index, isPlaying, loopMode, progress, duration, missing, - playTrack, playQueue, playNext, addToQueue, toggle, next, prev, seek, cycleLoop, + current, queue, index, isPlaying, loopMode, progress, duration, missing, longForm, speed, + playTrack, playQueue, playNext, addToQueue, toggle, next, prev, seek, cycleLoop, skip, cycleSpeed, } return ( @@ -329,7 +393,17 @@ export function PlayerProvider({ children }) { onLoadedMetadata={onLoadedMeta} onEnded={onEnded} onPlay={() => setIsPlaying(true)} - onPause={() => setIsPlaying(false)} + onPause={(e) => { + setIsPlaying(false) + persistPosition(e.target) + }} + onError={(e) => { + // A stream that can't load (offline, feed host down). Code 1 is an + // abort from switching tracks, which isn't a failure. + if (e.target.error?.code === 1 || !loadedRef.current) return + setIsPlaying(false) + setMissing(true) + }} /> </PlayerContext.Provider> ) From d6347c926e0abf021b888d5897ae4e26eaaccc09 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz <enesyilmaz5157@gmail.com> Date: Fri, 25 Sep 2026 21:36:57 -0400 Subject: [PATCH 4/5] feat(podcasts): Podcasts tab to follow shows and play episodes --- src/App.jsx | 6 + src/components/PodcastsView.jsx | 279 ++++++++++++++++++++++++++++++++ src/components/PromptModal.jsx | 8 +- src/index.css | 24 +++ src/state/useLibrary.js | 20 +++ vite.config.js | 21 ++- 6 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 src/components/PodcastsView.jsx diff --git a/src/App.jsx b/src/App.jsx index e1cff44..93e3ff9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import { UIProvider } from './state/UIProvider' import SearchView from './components/SearchView' import LibraryView from './components/LibraryView' import PlaylistsView from './components/PlaylistsView' +import PodcastsView from './components/PodcastsView' import Player from './components/Player' import AddToPlaylistSheet from './components/AddToPlaylistSheet' @@ -11,6 +12,7 @@ const TABS = [ { id: 'search', label: 'Search', icon: SearchIcon }, { id: 'library', label: 'Library', icon: LibraryIcon }, { id: 'playlists', label: 'Playlists', icon: PlaylistIcon }, + { id: 'podcasts', label: 'Podcasts', icon: PodcastIcon }, ] export default function App() { @@ -24,6 +26,7 @@ export default function App() { {tab === 'search' && <SearchView />} {tab === 'library' && <LibraryView />} {tab === 'playlists' && <PlaylistsView />} + {tab === 'podcasts' && <PodcastsView />} </main> {/* Floating dock: mini-player card stacked above the tab bar */} @@ -59,3 +62,6 @@ function LibraryIcon() { function PlaylistIcon() { return <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h13M3 12h9M3 18h9" /><path d="M16 13v6" /><circle cx="19" cy="19" r="2.5" /></svg> } +function PodcastIcon() { + return <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="3" width="6" height="11" rx="3" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3" /></svg> +} diff --git a/src/components/PodcastsView.jsx b/src/components/PodcastsView.jsx new file mode 100644 index 0000000..c17b0a5 --- /dev/null +++ b/src/components/PodcastsView.jsx @@ -0,0 +1,279 @@ +import { useState, useEffect } from 'react' +import { usePodcasts, useEpisodes, useInProgress } from '../state/useLibrary' +import { usePlayer } from '../state/PlayerProvider' +import { unsubscribePodcast, removeEpisodeAudio, setPlayed } from '../lib/db' +import { searchPodcasts, subscribe, refreshStale, toPlayable, downloadEpisode, useDownloads } from '../lib/podcasts' +import { formatTime, timeLeft } from '../lib/format' +import { useDialog } from '../lib/useDialog' +import Artwork from './Artwork' +import PromptModal from './PromptModal' +import ConfirmModal from './ConfirmModal' + +export default function PodcastsView() { + const shows = usePodcasts() + const [openId, setOpenId] = useState(null) + const [adding, setAdding] = useState(false) + + // Pick up new episodes when the tab opens (feeds older than an hour). + useEffect(() => { + refreshStale() + }, []) + + const open = shows?.find((s) => s.id === openId) + + return ( + <section className="view"> + {shows === undefined ? ( + <p className="dim">Loading…</p> + ) : adding ? ( + <AddPodcast subscribed={shows} onBack={() => setAdding(false)} onOpen={(id) => { setAdding(false); setOpenId(id) }} /> + ) : open ? ( + <ShowDetail show={open} onBack={() => setOpenId(null)} /> + ) : ( + <> + <header className="phead"> + <h1>Podcasts</h1> + <button className="btn btn--accent" onClick={() => setAdding(true)}>+ Add</button> + </header> + <ContinueListening shows={shows} /> + {shows.length === 0 ? ( + <p className="dim">No shows yet. Tap + Add to search for a podcast or paste its RSS link.</p> + ) : ( + <div className="list"> + {shows.map((s) => ( + <button key={s.id} className="showrow" onClick={() => setOpenId(s.id)}> + <Artwork track={{ id: s.id, thumbnailUrl: s.image }} size={56} radius={10} /> + <span className="row__meta"> + <span className="row__title">{s.title}</span> + <span className="row__artist">{s.author}</span> + </span> + </button> + ))} + </div> + )} + </> + )} + </section> + ) +} + +function ContinueListening({ shows }) { + const eps = useInProgress() + if (!eps?.length) return null + const byId = new Map(shows.map((s) => [s.id, s])) + return ( + <> + <p className="sheet__label">Continue listening</p> + <div className="list continue"> + {eps.map((ep) => <EpisodeRow key={ep.id} ep={ep} show={byId.get(ep.podcastId)} showName />)} + </div> + </> + ) +} + +function AddPodcast({ subscribed, onBack, onOpen }) { + const [query, setQuery] = useState('') + const [results, setResults] = useState(null) + const [status, setStatus] = useState('') // search / subscribe errors + const [busy, setBusy] = useState(null) // feed id being subscribed + const [pasting, setPasting] = useState(false) + const have = new Set(subscribed.map((s) => s.id)) + + // Search as you type, debounced so each keystroke isn't a request. + useEffect(() => { + const q = query.trim() + if (!q) { setResults(null); setStatus(''); return } + let stale = false + const t = setTimeout(() => { + searchPodcasts(q) + .then((r) => { if (!stale) { setResults(r); setStatus('') } }) + .catch(() => { if (!stale) setStatus('Search needs a connection. Try again when you are online.') }) + }, 400) + return () => { stale = true; clearTimeout(t) } + }, [query]) + + const add = async (feedUrl) => { + setBusy(feedUrl) + setStatus('') + try { + const show = await subscribe(feedUrl) + onOpen(show.id) + } catch { + setStatus("Couldn't read that feed. Check the link, or try again online.") + } finally { + setBusy(null) + } + } + + return ( + <> + <header className="phead"> + <button className="iconbtn phead__back" onClick={onBack} aria-label="Back"><ChevronLeft /></button> + <h1>Add a show</h1> + </header> + <div className="searchbar"> + <input + className="searchbar__input" + type="search" + inputMode="search" + placeholder="Search podcasts" + value={query} + onChange={(e) => setQuery(e.target.value)} + autoCapitalize="none" + autoCorrect="off" + autoFocus + /> + </div> + <button className="btn btn--ghost rsslink" onClick={() => setPasting(true)}>Paste an RSS link instead</button> + {status && <p className="now__missing" role="status">{status}</p>} + {results && results.length === 0 && <p className="dim searchhint">No shows match “{query.trim()}”.</p>} + {results && ( + <div className="list"> + {results.map((r) => ( + <div key={r.id} className="row"> + <span className="row__main row__main--static"> + <Artwork track={{ id: r.id, thumbnailUrl: r.image }} size={48} /> + <span className="row__meta"> + <span className="row__title">{r.title}</span> + <span className="row__artist">{r.author}</span> + </span> + </span> + {have.has(r.id) ? ( + <button className="btn btn--ghost subbtn" onClick={() => onOpen(r.id)}>Open</button> + ) : ( + <button className="btn btn--accent subbtn" onClick={() => add(r.id)} disabled={busy != null}> + {busy === r.id ? '…' : 'Follow'} + </button> + )} + </div> + ))} + </div> + )} + {pasting && ( + <PromptModal + title="Podcast RSS link" + placeholder="https://…" + confirmLabel="Follow" + maxLength={2000} + inputMode="url" + autoCapitalize="none" + onClose={() => setPasting(false)} + onSubmit={(url) => { setPasting(false); add(url) }} + /> + )} + </> + ) +} + +function ShowDetail({ show, onBack }) { + const eps = useEpisodes(show.id) + const [confirm, setConfirm] = useState(false) + + return ( + <> + <header className="phead"> + <button className="iconbtn phead__back" onClick={onBack} aria-label="Back"><ChevronLeft /></button> + <h1>{show.title}</h1> + </header> + <div className="showhead"> + <Artwork track={{ id: show.id, thumbnailUrl: show.image }} size={96} radius={14} /> + <div className="showhead__meta"> + <p className="dim">{show.author}</p> + <button className="btn btn--ghost" onClick={() => setConfirm(true)}>Unfollow</button> + </div> + </div> + {eps === undefined ? ( + <p className="dim">Loading…</p> + ) : eps.length === 0 ? ( + <p className="dim">This feed has no playable episodes.</p> + ) : ( + <div className="list"> + {eps.map((ep) => <EpisodeRow key={ep.id} ep={ep} show={show} />)} + </div> + )} + {confirm && ( + <ConfirmModal + title="Unfollow?" + message={`“${show.title}” and its downloaded episodes will be removed from this phone.`} + confirmLabel="Unfollow" + onClose={() => setConfirm(false)} + onConfirm={async () => { await unsubscribePodcast(show.id); setConfirm(false); onBack() }} + /> + )} + </> + ) +} + +function EpisodeRow({ ep, show, showName = false }) { + const { current, isPlaying, playTrack } = usePlayer() + const downloads = useDownloads() + const [menu, setMenu] = useState(false) + const [error, setError] = useState('') + const isCurrent = current?.id === ep.id + const dl = downloads[ep.id] + const pct = ep.duration && ep.position ? Math.min(100, (ep.position / ep.duration) * 100) : 0 + + const meta = [ + showName ? show?.title : ep.pubDate ? new Date(ep.pubDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : null, + ep.played ? 'Played' : ep.position > 0 && ep.duration ? timeLeft(ep.duration, ep.position) : ep.duration ? formatTime(ep.duration) : null, + ep.downloaded ? 'Downloaded' : null, + ].filter(Boolean).join(' · ') + + const download = () => { + setError('') + downloadEpisode(ep).catch(() => setError("This show doesn't allow downloads here. It will still stream.")) + } + + return ( + <div className={`row eprow ${isCurrent ? 'row--active' : ''} ${ep.played ? 'eprow--played' : ''}`}> + <button className="row__main" onClick={() => playTrack(toPlayable(ep, show))}> + <span className="row__meta"> + <span className="row__title eprow__title">{ep.title}</span> + <span className="row__artist">{error || meta}</span> + {pct > 0 && !ep.played && <span className="eprow__bar"><i style={{ width: `${pct}%` }} /></span>} + </span> + {isCurrent && isPlaying && <span className="eq" aria-hidden="true"><i /><i /><i /></span>} + </button> + {dl !== undefined ? ( + <span className="eprow__dl" aria-label="Downloading">{dl < 0 ? '…' : `${Math.round(dl * 100)}%`}</span> + ) : !ep.downloaded ? ( + <button className="iconbtn" onClick={download} aria-label="Download"><DownloadIcon /></button> + ) : null} + <button className="iconbtn" onClick={() => setMenu(true)} aria-label="Episode options"><Dots /></button> + {menu && ( + <EpisodeSheet + ep={ep} + onClose={() => setMenu(false)} + onDownload={() => { setMenu(false); download() }} + /> + )} + </div> + ) +} + +function EpisodeSheet({ ep, onClose, onDownload }) { + const dialog = useDialog(onClose) + const act = (fn) => async () => { await fn().catch(() => {}); onClose() } + return ( + <div className="sheet-overlay" onClick={onClose}> + <div className="sheet" ref={dialog.ref} onKeyDown={dialog.onKeyDown} role="dialog" aria-modal="true" tabIndex={-1} onClick={(e) => e.stopPropagation()}> + <div className="sheet__grip" /> + <p className="sheet__title">{ep.title}</p> + {ep.summary && <p className="dim episheet__summary">{ep.summary}</p>} + <button className="sheet__item" onClick={act(() => setPlayed({ ...ep, kind: 'episode' }, !ep.played))}> + {ep.played ? 'Mark as unplayed' : 'Mark as played'} + </button> + {ep.downloaded ? ( + <button className="sheet__item sheet__item--danger" onClick={act(() => removeEpisodeAudio(ep.id))}>Remove download</button> + ) : ( + <button className="sheet__item" onClick={onDownload}>Download for offline</button> + )} + <button className="btn btn--ghost sheet__cancel" onClick={onClose}>Cancel</button> + </div> + </div> + ) +} + +function ChevronLeft() { return <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M15 6l-6 6 6 6" /></svg> } +function Dots() { return <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" /></svg> } +function DownloadIcon() { return <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg> } diff --git a/src/components/PromptModal.jsx b/src/components/PromptModal.jsx index 2dacf85..56b8df7 100644 --- a/src/components/PromptModal.jsx +++ b/src/components/PromptModal.jsx @@ -9,6 +9,9 @@ export default function PromptModal({ placeholder = '', confirmLabel = 'Create', initialValue = '', + maxLength = 60, + inputMode = 'text', + autoCapitalize = 'words', onSubmit, onClose, }) { @@ -46,10 +49,11 @@ export default function PromptModal({ value={value} onChange={(e) => setValue(e.target.value)} placeholder={placeholder} - autoCapitalize="words" + inputMode={inputMode} + autoCapitalize={autoCapitalize} autoCorrect="off" enterKeyHint="done" - maxLength={60} + maxLength={maxLength} /> <div className="modal__actions"> <button type="button" className="btn btn--ghost" onClick={onClose}>Cancel</button> diff --git a/src/index.css b/src/index.css index f52171a..be83137 100644 --- a/src/index.css +++ b/src/index.css @@ -489,3 +489,27 @@ body { 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; } +.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; } +.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; } diff --git a/src/state/useLibrary.js b/src/state/useLibrary.js index 29f9cb8..e76b14c 100644 --- a/src/state/useLibrary.js +++ b/src/state/useLibrary.js @@ -50,3 +50,23 @@ export function useSearch(tracks, query) { return fuse.search(q).map((r) => r.item) }, [fuse, query, tracks]) } + +export function usePodcasts() { + return useLiveQuery(() => db.podcasts.orderBy('title').toArray()) +} + +/** A show's episodes, newest first. */ +export function useEpisodes(podcastId) { + return useLiveQuery( + () => (podcastId == null ? [] : db.episodes.where('podcastId').equals(podcastId).reverse().sortBy('pubDate')), + [podcastId], + ) +} + +/** Episodes started but not finished, most recently played first. */ +export function useInProgress() { + return useLiveQuery(async () => { + const eps = await db.episodes.filter((e) => e.position > 0 && !e.played).toArray() + return eps.sort((a, b) => (b.lastPlayedAt || 0) - (a.lastPlayedAt || 0)).slice(0, 5) + }) +} diff --git a/vite.config.js b/vite.config.js index d71abb9..1d9ff84 100644 --- a/vite.config.js +++ b/vite.config.js @@ -20,14 +20,16 @@ const stripDevSamplesPlugin = () => ({ // block. Sources: app assets are same-origin ('self'); thumbnails come from // YouTube's image CDNs; JSON is fetched from noembed + lrclib; imported audio // and artwork play from blob: URLs; React sets inline style attributes -// ('unsafe-inline' for style only, never script). +// ('unsafe-inline' for style only, never script). Podcasts widen img, media +// and connect to any https: host, since feeds, cover art and episode audio +// live wherever each show is hosted. const CSP = [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https://i.ytimg.com https://*.ytimg.com https://*.ggpht.com", - "media-src 'self' blob:", - "connect-src 'self' https://noembed.com https://lrclib.net", + "img-src 'self' data: blob: https:", + "media-src 'self' blob: https:", + "connect-src 'self' https:", "font-src 'self'", "manifest-src 'self'", "worker-src 'self'", @@ -117,6 +119,17 @@ export default defineConfig(({ command }) => ({ expiration: { maxEntries: 300, maxAgeSeconds: 60 * 60 * 24 * 90 }, }, }, + // Podcast cover art, from whatever host each show uses. Opaque + // (status 0) responses are fine to cache for an <img>. + { + urlPattern: ({ request, url }) => request.destination === 'image' && url.origin !== self.location.origin, + handler: 'CacheFirst', + options: { + cacheName: 'podcast-art', + cacheableResponse: { statuses: [0, 200] }, + expiration: { maxEntries: 300, maxAgeSeconds: 60 * 60 * 24 * 90 }, + }, + }, ], }, From 2a04b5e659bce4e87fbd88bee583ce13add16986 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz <enesyilmaz5157@gmail.com> Date: Fri, 25 Sep 2026 21:36:59 -0400 Subject: [PATCH 5/5] test(podcasts): end-to-end check against real feeds --- scripts/shoot-podcasts.mjs | 40 ++++++++++++ scripts/test-podcasts.mjs | 121 +++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 scripts/shoot-podcasts.mjs create mode 100644 scripts/test-podcasts.mjs diff --git a/scripts/shoot-podcasts.mjs b/scripts/shoot-podcasts.mjs new file mode 100644 index 0000000..4151712 --- /dev/null +++ b/scripts/shoot-podcasts.mjs @@ -0,0 +1,40 @@ +// Screenshots of the Podcasts tab: search results, a show's episode list, +// and the tab with Continue listening. node scripts/shoot-podcasts.mjs +import { chromium, devices } from 'playwright' +import { createServer } from 'vite' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const shot = (name) => resolve(here, `../screenshots/${name}.png`) +const server = await createServer({ root: resolve(here, '..'), server: { port: 5192, strictPort: true }, logLevel: 'error' }) +await server.listen() + +const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] }) +const page = await (await browser.newContext({ ...devices['iPhone 13'] })).newPage() +try { + await page.goto('http://localhost:5192', { waitUntil: 'networkidle' }) + await page.getByRole('button', { name: 'Podcasts' }).click() + await page.getByRole('button', { name: '+ Add' }).click() + await page.getByPlaceholder('Search podcasts').fill('huberman') + await page.locator('.subbtn').first().waitFor({ timeout: 15000 }) + await page.waitForTimeout(1500) + await page.screenshot({ path: shot('11-podcast-search') }) + + await page.locator('.subbtn').first().click() + await page.locator('.eprow').first().waitFor({ timeout: 30000 }) + await page.locator('.eprow .row__main').nth(1).click() + await page.waitForFunction(() => document.querySelector('audio')?.currentTime > 0.5, null, { timeout: 30000 }) + await page.evaluate(() => { const a = document.querySelector('audio'); a.currentTime = 600 }) + await page.waitForTimeout(1500) + await page.locator('.mini').getByLabel('Pause').click() + await page.waitForTimeout(800) + await page.screenshot({ path: shot('12-podcast-show') }) + + await page.getByLabel('Back').click() + await page.waitForTimeout(800) + await page.screenshot({ path: shot('13-podcasts-tab') }) +} finally { + await browser.close() + await server.close() +} diff --git a/scripts/test-podcasts.mjs b/scripts/test-podcasts.mjs new file mode 100644 index 0000000..a99535a --- /dev/null +++ b/scripts/test-podcasts.mjs @@ -0,0 +1,121 @@ +// End-to-end check of podcasts against the real network: search → follow → +// play → speed → position saved → resume after reload → download → offline +// play. Also checks that a long imported track gets podcast controls. +// Starts its own Vite dev server, so just: node scripts/test-podcasts.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: 5191, strictPort: true }, logLevel: 'error' }) +await server.listen() +const base = 'http://localhost:5191' + +const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] }) +const ctx = await browser.newContext({ ...devices['iPhone 13'] }) +const page = await ctx.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++ +} +const audio = (fn) => page.evaluate(fn) +const readEpisodes = () => page.evaluate(() => new Promise((res) => { + const r = indexedDB.open('melody') + r.onsuccess = () => { + const q = r.result.transaction('episodes').objectStore('episodes').getAll() + q.onsuccess = () => res(q.result) + } +})) + +try { + await page.goto(base, { waitUntil: 'networkidle' }) + await page.getByRole('button', { name: 'Podcasts' }).click() + await page.getByRole('button', { name: '+ Add' }).click() + await page.getByPlaceholder('Search podcasts').fill('the daily new york times') + await page.locator('.subbtn').first().waitFor({ timeout: 15000 }) + const firstResult = await page.locator('.row__title').first().textContent() + check('iTunes search returns shows', !!firstResult, firstResult) + + await page.locator('.subbtn').first().click() + await page.locator('.eprow').first().waitFor({ timeout: 30000 }) + const epCount = await page.locator('.eprow').count() + check('follow loads episodes from the RSS feed', epCount > 5, `${epCount} episodes`) + + // Play the newest episode and open Now Playing. + await page.locator('.eprow .row__main').first().click() + await page.waitForFunction(() => { const a = document.querySelector('audio'); return a && a.currentTime > 0.5 }, null, { timeout: 30000 }) + check('episode streams', true) + await page.locator('.mini').click() + check('Now Playing shows skip buttons', await page.getByLabel('Forward 30 seconds').isVisible()) + check('Now Playing shows speed pill instead of repeat', await page.locator('.speedbtn').isVisible()) + + await page.locator('.speedbtn').click() + const rate = await audio(() => document.querySelector('audio').playbackRate) + check('speed pill sets playbackRate', rate === 1.25, `rate ${rate}`) + + await page.getByLabel('Forward 30 seconds').click() + await page.getByLabel('Forward 30 seconds').click() + await page.getByLabel('Forward 30 seconds').click() + const t = await audio(() => document.querySelector('audio').currentTime) + check('skip forward moves 30s per tap', t >= 90, `t=${t.toFixed(1)}`) + + await page.locator('.now .playbtn').click() + await page.waitForTimeout(500) + const saved = (await readEpisodes()).find((e) => e.position > 0) + check('pausing saves the position', saved && Math.abs(saved.position - t) < 5, saved ? `saved ${saved.position.toFixed(1)}` : 'none saved') + + // Reload: the episode should be in Continue listening and resume in place. + await page.reload({ waitUntil: 'networkidle' }) + await page.getByRole('button', { name: 'Podcasts' }).click() + const cont = page.locator('.continue .eprow') + await cont.first().waitFor({ timeout: 5000 }) + check('Continue listening lists it after reload', await cont.count() === 1, await cont.first().locator('.row__artist').textContent()) + await cont.first().locator('.row__main').click() + await page.waitForFunction(() => { const a = document.querySelector('audio'); return a && a.currentTime > 60 }, null, { timeout: 30000 }).catch(() => {}) + const resumed = await audio(() => document.querySelector('audio').currentTime) + check('playback resumes at the saved spot', resumed >= saved.position - 2, `t=${resumed.toFixed(1)}`) + const rate2 = await audio(() => document.querySelector('audio').playbackRate) + check('speed survives reload', rate2 === 1.25, `rate ${rate2}`) + await page.locator('.mini').getByLabel('Pause').click() + + // Download the newest episode, then play it with the network cut. + await page.locator('.showrow').first().click() + const row = page.locator('.eprow').first() + await row.getByLabel('Download').click() + await page.waitForFunction(() => document.querySelector('.eprow .row__artist')?.textContent.includes('Downloaded'), null, { timeout: 180000 }) + check('download completes', true) + await ctx.setOffline(true) + await row.locator('.row__main').click() + await page.waitForFunction(() => { const a = document.querySelector('audio'); return a && a.src.startsWith('blob:') && !a.paused }, null, { timeout: 15000 }).catch(() => {}) + const src = await audio(() => document.querySelector('audio').src) + check('downloaded episode plays offline from local storage', src.startsWith('blob:'), src.slice(0, 30)) + await ctx.setOffline(false) + + // A long imported track (a 21-minute silent WAV) gets podcast controls. + await page.getByRole('button', { name: 'Library' }).click() + const sec = 21 * 60, rateHz = 8000 + const wav = Buffer.alloc(44 + sec * rateHz) + wav.write('RIFF', 0); wav.writeUInt32LE(36 + sec * rateHz, 4); wav.write('WAVEfmt ', 8) + wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22) + wav.writeUInt32LE(rateHz, 24); wav.writeUInt32LE(rateHz, 28); wav.writeUInt16LE(1, 32); wav.writeUInt16LE(8, 34) + wav.write('data', 36); wav.writeUInt32LE(sec * rateHz, 40); wav.fill(128, 44) + await page.locator('input[type=file]').setInputFiles({ name: 'Long Talk - Interview.wav', mimeType: 'audio/wav', buffer: wav }) + await page.getByText('Interview').first().waitFor({ timeout: 15000 }) + await page.getByText('Interview').first().click() + await page.locator('.mini').click() + check('long imported track gets skip buttons', await page.getByLabel('Back 15 seconds').isVisible()) + + await page.screenshot({ path: resolve(here, '../screenshots/10-podcast-now-playing.png') }) +} 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)