final feature set

This commit is contained in:
2026-02-05 22:55:24 -05:00
parent 6b00871c32
commit 168e6d5a61
115 changed files with 2401 additions and 4468 deletions

View File

@@ -3,8 +3,11 @@ import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings"
import { resolveTheme } from "../utils/theme-resolver"
import type { ThemeJson } from "../types/theme-schema"
const STORAGE_KEY = "podtui_app_state"
import {
loadAppStateFromFile,
saveAppStateToFile,
migrateAppStateFromLocalStorage,
} from "../utils/app-persistence"
const defaultSettings: AppSettings = {
theme: "system",
@@ -24,33 +27,21 @@ const defaultState: AppState = {
customTheme: DEFAULT_THEME,
}
const loadState = (): AppState => {
if (typeof localStorage === "undefined") return defaultState
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultState
const parsed = JSON.parse(raw) as Partial<AppState>
return {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
}
} catch {
return defaultState
}
}
const saveState = (state: AppState) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {
// ignore storage errors
}
}
export function createAppStore() {
const [state, setState] = createSignal<AppState>(loadState())
// Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState)
// Fire-and-forget async initialisation
const init = async () => {
await migrateAppStateFromLocalStorage()
const loaded = await loadAppStateFromFile()
setState(loaded)
}
init()
const saveState = (next: AppState) => {
saveAppStateToFile(next).catch(() => {})
}
const updateState = (next: AppState) => {
setState(next)

View File

@@ -11,6 +11,14 @@ import type { Episode, EpisodeStatus } from "../types/episode"
import type { PodcastSource, SourceType } from "../types/source"
import { DEFAULT_SOURCES } from "../types/source"
import { parseRSSFeed } from "../api/rss-parser"
import {
loadFeedsFromFile,
saveFeedsToFile,
loadSourcesFromFile,
saveSourcesToFile,
migrateFeedsFromLocalStorage,
migrateSourcesFromLocalStorage,
} from "../utils/feeds-persistence"
/** Max episodes to fetch on refresh */
const MAX_EPISODES_REFRESH = 50
@@ -18,85 +26,30 @@ const MAX_EPISODES_REFRESH = 50
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20
/** Storage keys */
const STORAGE_KEYS = {
feeds: "podtui_feeds",
sources: "podtui_sources",
}
/** Load feeds from localStorage */
function loadFeeds(): Feed[] {
if (typeof localStorage === "undefined") {
return []
}
try {
const stored = localStorage.getItem(STORAGE_KEYS.feeds)
if (stored) {
const parsed = JSON.parse(stored)
// Convert date strings
return parsed.map((feed: Feed) => ({
...feed,
lastUpdated: new Date(feed.lastUpdated),
podcast: {
...feed.podcast,
lastUpdated: new Date(feed.podcast.lastUpdated),
},
episodes: feed.episodes.map((ep: Episode) => ({
...ep,
pubDate: new Date(ep.pubDate),
})),
}))
}
} catch {
// Ignore errors
}
return []
}
/** Save feeds to localStorage */
/** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
} catch {
// Ignore errors
}
saveFeedsToFile(feeds).catch(() => {})
}
/** Load sources from localStorage */
function loadSources(): PodcastSource[] {
if (typeof localStorage === "undefined") {
return [...DEFAULT_SOURCES]
}
try {
const stored = localStorage.getItem(STORAGE_KEYS.sources)
if (stored) {
return JSON.parse(stored)
}
} catch {
// Ignore errors
}
return [...DEFAULT_SOURCES]
}
/** Save sources to localStorage */
/** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.sources, JSON.stringify(sources))
} catch {
// Ignore errors
}
saveSourcesToFile(sources).catch(() => {})
}
/** Create feed store */
export function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>(loadFeeds())
const [sources, setSources] = createSignal<PodcastSource[]>(loadSources())
const [feeds, setFeeds] = createSignal<Feed[]>([])
const [sources, setSources] = createSignal<PodcastSource[]>([...DEFAULT_SOURCES])
// Async initialization: migrate from localStorage, then load from file
;(async () => {
await migrateFeedsFromLocalStorage()
await migrateSourcesFromLocalStorage()
const loadedFeeds = await loadFeedsFromFile()
if (loadedFeeds.length > 0) setFeeds(loadedFeeds)
const loadedSources = await loadSourcesFromFile<PodcastSource>()
if (loadedSources && loadedSources.length > 0) setSources(loadedSources)
})()
const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all",
sortBy: "updated" as FeedSortField,

View File

@@ -1,14 +1,17 @@
/**
* Episode progress store for PodTUI
*
* Persists per-episode playback progress to localStorage.
* Persists per-episode playback progress to a JSON file in XDG_CONFIG_HOME.
* Tracks position, duration, completion, and last-played timestamp.
*/
import { createSignal } from "solid-js"
import type { Progress } from "../types/episode"
const STORAGE_KEY = "podtui_progress"
import {
loadProgressFromFile,
saveProgressToFile,
migrateProgressFromLocalStorage,
} from "../utils/app-persistence"
/** Threshold (fraction 0-1) at which an episode is considered completed */
const COMPLETION_THRESHOLD = 0.95
@@ -16,48 +19,42 @@ const COMPLETION_THRESHOLD = 0.95
/** Minimum seconds of progress before persisting */
const MIN_POSITION_TO_SAVE = 5
// --- localStorage helpers ---
function loadProgress(): Record<string, Progress> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
const result: Record<string, Progress> = {}
for (const [key, value] of Object.entries(parsed)) {
const p = value as Record<string, unknown>
result[key] = {
episodeId: p.episodeId as string,
position: p.position as number,
duration: p.duration as number,
timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined,
}
}
return result
} catch {
return {}
}
}
function saveProgress(data: Record<string, Progress>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch {
// Quota exceeded or unavailable — silently ignore
}
}
// --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
loadProgress(),
)
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>({})
/** Persist current progress map to file (fire-and-forget) */
function persist(): void {
saveProgress(progressMap())
saveProgressToFile(progressMap()).catch(() => {})
}
/** Parse raw progress entries from file, reviving Date objects */
function parseProgressEntries(raw: Record<string, unknown>): Record<string, Progress> {
const result: Record<string, Progress> = {}
for (const [key, value] of Object.entries(raw)) {
const p = value as Record<string, unknown>
result[key] = {
episodeId: p.episodeId as string,
position: p.position as number,
duration: p.duration as number,
timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined,
}
}
return result
}
/** Async initialisation — migrate from localStorage then load from file */
async function initProgress(): Promise<void> {
await migrateProgressFromLocalStorage()
const raw = await loadProgressFromFile()
const parsed = parseProgressEntries(raw as Record<string, unknown>)
setProgressMap(parsed)
}
// Fire-and-forget init
initProgress()
function createProgressStore() {
return {
/**