cleaning up code
This commit is contained in:
@@ -38,6 +38,7 @@ const defaultSettings: AppSettings = {
|
||||
const defaultPreferences: UserPreferences = {
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
autoJumpToPlayer: true,
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
|
||||
@@ -135,10 +135,8 @@ export class AudioStreamReader {
|
||||
this.writePos = 0;
|
||||
this.totalSamplesWritten = 0;
|
||||
|
||||
// Capture generation for this run
|
||||
const myGeneration = this.generation;
|
||||
|
||||
// Start async reading loop
|
||||
this.readLoop(myGeneration);
|
||||
|
||||
// Detect process exit
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Audio waveform analysis for PodTUI
|
||||
*
|
||||
* Extracts amplitude data from audio files using ffmpeg (when available)
|
||||
* Results are cache in-memory keyed by audio URL.
|
||||
*/
|
||||
|
||||
/** Number of amplitude data points to generate */
|
||||
const DEFAULT_RESOLUTION = 128;
|
||||
|
||||
/** In-memory cache: audioUrl -> amplitude data */
|
||||
const waveformCache = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Try to extract real waveform data from an audio URL using ffmpeg.
|
||||
* Returns null if ffmpeg is not available or the extraction fails.
|
||||
*/
|
||||
async function extractWithFfmpeg(
|
||||
audioUrl: string,
|
||||
resolution: number,
|
||||
): Promise<number[] | null> {
|
||||
try {
|
||||
if (!Bun.which("ffmpeg")) return null;
|
||||
|
||||
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
|
||||
// -t 300: read at most 5 minutes (enough data to fill the waveform)
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
audioUrl,
|
||||
"-t",
|
||||
"300",
|
||||
"-ac",
|
||||
"1", // mono
|
||||
"-ar",
|
||||
"8000", // low sample rate to keep data small
|
||||
"-f",
|
||||
"s16le", // raw signed 16-bit PCM
|
||||
"-v",
|
||||
"quiet",
|
||||
"-",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "ignore" },
|
||||
);
|
||||
|
||||
const output = await new Response(proc.stdout).arrayBuffer();
|
||||
await proc.exited;
|
||||
|
||||
if (output.byteLength === 0) return null;
|
||||
|
||||
const samples = new Int16Array(output);
|
||||
if (samples.length === 0) return null;
|
||||
|
||||
// Downsample to `resolution` buckets by taking the max absolute amplitude
|
||||
// in each bucket.
|
||||
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
|
||||
const data: number[] = [];
|
||||
|
||||
for (let i = 0; i < resolution; i++) {
|
||||
const start = i * bucketSize;
|
||||
const end = Math.min(start + bucketSize, samples.length);
|
||||
let maxAbs = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(samples[j]);
|
||||
if (abs > maxAbs) maxAbs = abs;
|
||||
}
|
||||
// Normalise to 0-1
|
||||
data.push(Number((maxAbs / 32768).toFixed(3)));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get waveform data for an audio URL.
|
||||
*
|
||||
* Returns cached data if available, otherwise attempts ffmpeg extraction
|
||||
*/
|
||||
export async function getWaveformData(
|
||||
audioUrl: string,
|
||||
resolution: number = DEFAULT_RESOLUTION,
|
||||
): Promise<number[]> {
|
||||
const cacheKey = `${audioUrl}:${resolution}`;
|
||||
const cached = waveformCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const real = await extractWithFfmpeg(audioUrl, resolution);
|
||||
if (real) {
|
||||
waveformCache.set(cacheKey, real);
|
||||
return real;
|
||||
} else {
|
||||
console.error("generation failure");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWaveformCache(): void {
|
||||
waveformCache.clear();
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
type CacheEntry<T> = {
|
||||
value: T
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const CACHE_KEY = "podtui_cache"
|
||||
const DEFAULT_TTL = 1000 * 60 * 60
|
||||
|
||||
const loadCache = (): Record<string, CacheEntry<unknown>> => {
|
||||
if (typeof localStorage === "undefined") return {}
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
|
||||
if (typeof localStorage === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const cache = loadCache()
|
||||
|
||||
export const cacheValue = <T,>(key: string, value: T) => {
|
||||
cache[key] = { value, timestamp: Date.now() }
|
||||
saveCache(cache)
|
||||
}
|
||||
|
||||
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
|
||||
const entry = cache[key] as CacheEntry<T> | undefined
|
||||
if (!entry) return null
|
||||
if (Date.now() - entry.timestamp > ttl) {
|
||||
delete cache[key]
|
||||
saveCache(cache)
|
||||
return null
|
||||
}
|
||||
return entry.value
|
||||
}
|
||||
|
||||
export const invalidateCache = (prefix?: string) => {
|
||||
if (!prefix) {
|
||||
Object.keys(cache).forEach((key) => delete cache[key])
|
||||
saveCache(cache)
|
||||
return
|
||||
}
|
||||
|
||||
Object.keys(cache)
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.forEach((key) => delete cache[key])
|
||||
saveCache(cache)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export namespace Clipboard {
|
||||
/**
|
||||
* Read text from the clipboard.
|
||||
*/
|
||||
export async function readText(): Promise<string | undefined> {
|
||||
async function readText(): Promise<string | undefined> {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin") {
|
||||
|
||||
@@ -13,7 +13,7 @@ import path from "path"
|
||||
const APP_DIR_NAME = "podtui"
|
||||
|
||||
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
|
||||
export function getXdgConfigHome(): string {
|
||||
function getXdgConfigHome(): string {
|
||||
const xdg = process.env.XDG_CONFIG_HOME
|
||||
if (xdg) return xdg
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise<string> {
|
||||
}
|
||||
|
||||
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
|
||||
export function getXdgDataHome(): string {
|
||||
function getXdgDataHome(): string {
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
if (xdg) return xdg
|
||||
|
||||
@@ -55,12 +55,12 @@ export function getXdgDataHome(): string {
|
||||
}
|
||||
|
||||
/** Get the application-specific data directory path */
|
||||
export function getDataDir(): string {
|
||||
function getDataDir(): string {
|
||||
return path.join(getXdgDataHome(), APP_DIR_NAME)
|
||||
}
|
||||
|
||||
/** Get the downloads directory path */
|
||||
export function getDownloadsDir(): string {
|
||||
function getDownloadsDir(): string {
|
||||
return path.join(getDataDir(), "downloads")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Validates JSON structure of config files, handles corrupted files
|
||||
* gracefully (falling back to defaults), and provides a single
|
||||
*/
|
||||
|
||||
import { getConfigFilePath } from "./config-dir";
|
||||
// --- Validation helpers ---
|
||||
|
||||
/** Check that a value is a non-null object */
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** Validate AppState JSON structure */
|
||||
export function validateAppState(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(data)) {
|
||||
return { valid: false, errors: ["app-state.json is not an object"] };
|
||||
}
|
||||
|
||||
// settings
|
||||
if (data.settings !== undefined) {
|
||||
if (!isObject(data.settings)) {
|
||||
errors.push("settings must be an object");
|
||||
} else {
|
||||
const s = data.settings as Record<string, unknown>;
|
||||
if (s.theme !== undefined && typeof s.theme !== "string")
|
||||
errors.push("settings.theme must be a string");
|
||||
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
|
||||
errors.push("settings.fontSize must be a number");
|
||||
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
|
||||
errors.push("settings.playbackSpeed must be a number");
|
||||
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
|
||||
errors.push("settings.downloadPath must be a string");
|
||||
}
|
||||
}
|
||||
|
||||
// preferences
|
||||
if (data.preferences !== undefined) {
|
||||
if (!isObject(data.preferences)) {
|
||||
errors.push("preferences must be an object");
|
||||
} else {
|
||||
const p = data.preferences as Record<string, unknown>;
|
||||
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
|
||||
errors.push("preferences.showExplicit must be a boolean");
|
||||
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
|
||||
errors.push("preferences.autoDownload must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
// customTheme
|
||||
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
|
||||
errors.push("customTheme must be an object");
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Validate feeds JSON structure */
|
||||
export function validateFeeds(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(data)) {
|
||||
return { valid: false, errors: ["feeds.json is not an array"] };
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const feed = data[i];
|
||||
if (!isObject(feed)) {
|
||||
errors.push(`feeds[${i}] is not an object`);
|
||||
continue;
|
||||
}
|
||||
if (typeof feed.id !== "string")
|
||||
errors.push(`feeds[${i}].id must be a string`);
|
||||
if (!isObject(feed.podcast))
|
||||
errors.push(`feeds[${i}].podcast must be an object`);
|
||||
if (!Array.isArray(feed.episodes))
|
||||
errors.push(`feeds[${i}].episodes must be an array`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Validate progress JSON structure */
|
||||
export function validateProgress(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(data)) {
|
||||
return { valid: false, errors: ["progress.json is not an object"] };
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (!isObject(value)) {
|
||||
errors.push(`progress["${key}"] is not an object`);
|
||||
continue;
|
||||
}
|
||||
const p = value as Record<string, unknown>;
|
||||
if (typeof p.episodeId !== "string")
|
||||
errors.push(`progress["${key}"].episodeId must be a string`);
|
||||
if (typeof p.position !== "number")
|
||||
errors.push(`progress["${key}"].position must be a number`);
|
||||
if (typeof p.duration !== "number")
|
||||
errors.push(`progress["${key}"].duration must be a number`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// --- Safe config file reading ---
|
||||
|
||||
/**
|
||||
* Safely read and validate a config file.
|
||||
* Returns the parsed data if valid, or null if the file is missing/corrupt.
|
||||
*/
|
||||
export async function safeReadConfigFile<T>(
|
||||
filename: string,
|
||||
validator: (data: unknown) => { valid: boolean; errors: string[] },
|
||||
): Promise<{ data: T | null; errors: string[] }> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(filename);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return { data: null, errors: [] };
|
||||
}
|
||||
|
||||
const text = await file.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { data: null, errors: [`${filename}: invalid JSON`] };
|
||||
}
|
||||
|
||||
const result = validator(parsed);
|
||||
if (!result.valid) {
|
||||
return { data: null, errors: result.errors };
|
||||
}
|
||||
|
||||
return { data: parsed as T, errors: [] };
|
||||
} catch (err) {
|
||||
return { data: null, errors: [`${filename}: ${String(err)}`] };
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,6 @@ export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Await all pending config writes (used by sync/export flows). */
|
||||
export async function flushConfig(): Promise<void> {
|
||||
await writeChain;
|
||||
}
|
||||
|
||||
/** Guards so migration runs exactly once per process. */
|
||||
let migrationDone = false;
|
||||
let migrationPromise: Promise<void> | null = null;
|
||||
@@ -98,7 +93,7 @@ async function migrateOnce(): Promise<void> {
|
||||
* Safe to call on every startup — no-op once config.json exists (except for
|
||||
* backup cleanup, which runs unconditionally since those files are now dead).
|
||||
*/
|
||||
export async function migrateLegacyConfig(): Promise<void> {
|
||||
async function migrateLegacyConfig(): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
const dir = getConfigDir();
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { FeedVisibility } from "../types/feed"
|
||||
import type { Feed } from "../types/feed"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { cacheValue, getCachedValue } from "./cache"
|
||||
import { fetchEpisodes } from "@/api/client"
|
||||
|
||||
const feedKey = (feedUrl: string) => `feed:${feedUrl}`
|
||||
const episodesKey = (feedUrl: string) => `episodes:${feedUrl}`
|
||||
const searchKey = (query: string) => `search:${query.toLowerCase()}`
|
||||
|
||||
export const fetchFeedWithCache = async (feedUrl: string): Promise<Feed | null> => {
|
||||
const cached = getCachedValue<Feed>(feedKey(feedUrl))
|
||||
if (cached) return cached
|
||||
try {
|
||||
const episodes = await fetchEpisodes(feedUrl)
|
||||
const feed: Feed = {
|
||||
id: feedUrl,
|
||||
podcast: {
|
||||
id: feedUrl,
|
||||
title: feedUrl,
|
||||
description: "",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes,
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId: "rss",
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
}
|
||||
cacheValue(feedKey(feedUrl), feed)
|
||||
return feed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchEpisodesWithCache = async (feedUrl: string): Promise<Episode[]> => {
|
||||
const cached = getCachedValue<Episode[]>(episodesKey(feedUrl))
|
||||
if (cached) return cached
|
||||
const episodes = await fetchEpisodes(feedUrl)
|
||||
cacheValue(episodesKey(feedUrl), episodes)
|
||||
return episodes
|
||||
}
|
||||
|
||||
export const searchWithCache = async (
|
||||
query: string,
|
||||
fetcher: () => Promise<Podcast[]>
|
||||
): Promise<Podcast[]> => {
|
||||
const cached = getCachedValue<Podcast[]>(searchKey(query))
|
||||
if (cached) return cached
|
||||
const results = await fetcher()
|
||||
cacheValue(searchKey(query), results)
|
||||
return results
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
||||
]);
|
||||
|
||||
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||
export function tabByDigit(action: KeybindActionName): TABS | null {
|
||||
function tabByDigit(action: KeybindActionName): TABS | null {
|
||||
if (action.startsWith("tab-goto-")) {
|
||||
const n = Number(action.slice("tab-goto-".length));
|
||||
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
||||
|
||||
@@ -87,7 +87,7 @@ function createEventBus(): EventBusInstance {
|
||||
}
|
||||
|
||||
// Singleton event bus instance
|
||||
export const EventBus = createEventBus();
|
||||
const EventBus = createEventBus();
|
||||
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { TABS } from "@/utils/navigation";
|
||||
@@ -105,6 +105,8 @@ export type AppEvents = {
|
||||
"player.play": { episodeId: string };
|
||||
"player.pause": { episodeId: string };
|
||||
"player.stop": {};
|
||||
// Emitted when a NEW episode begins playback (not on resume).
|
||||
"player.started": { episodeId: string };
|
||||
"toast.show": {
|
||||
message: string;
|
||||
variant: "info" | "success" | "warning" | "error";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/**
|
||||
* Remove JSONC comments from a string
|
||||
*/
|
||||
export function stripComments(jsonString: string): string {
|
||||
function stripComments(jsonString: string): string {
|
||||
const comments = [
|
||||
{ pattern: /\/\/.*$/gm, replacement: "" },
|
||||
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },
|
||||
|
||||
@@ -77,7 +77,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
|
||||
try {
|
||||
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||
|
||||
// Check if file already exists
|
||||
const targetFile = Bun.file(targetPath);
|
||||
if (await targetFile.exists()) return;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { searchSourceByType } from "./source-searcher";
|
||||
import type { PodcastSource, SearchResult } from "../types/source";
|
||||
import type { Episode } from "../types/episode";
|
||||
|
||||
type SearchCacheEntry = {
|
||||
timestamp: number;
|
||||
@@ -114,61 +113,4 @@ export const searchPodcasts = async (
|
||||
return sorted;
|
||||
};
|
||||
|
||||
type ItunesEpisodeResult = {
|
||||
trackId?: number;
|
||||
trackName?: string;
|
||||
description?: string;
|
||||
shortDescription?: string;
|
||||
releaseDate?: string;
|
||||
trackTimeMillis?: number;
|
||||
episodeUrl?: string;
|
||||
previewUrl?: string;
|
||||
trackViewUrl?: string;
|
||||
};
|
||||
|
||||
type ItunesEpisodeResponse = {
|
||||
resultCount: number;
|
||||
results: ItunesEpisodeResult[];
|
||||
};
|
||||
|
||||
export const searchEpisodes = async (
|
||||
query: string,
|
||||
feedId: string,
|
||||
): Promise<Episode[]> => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const url = new URL("https://itunes.apple.com/search");
|
||||
url.searchParams.set("term", trimmed);
|
||||
url.searchParams.set("media", "podcast");
|
||||
url.searchParams.set("entity", "podcastEpisode");
|
||||
url.searchParams.set("country", "US");
|
||||
url.searchParams.set("lang", "en_us");
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = (await response.json()) as ItunesEpisodeResponse;
|
||||
return data.results
|
||||
.map((item) => {
|
||||
if (!item.trackName) return null;
|
||||
const id = item.trackId
|
||||
? `episode-${item.trackId}`
|
||||
: `episode-${item.trackName}`;
|
||||
const audioUrl =
|
||||
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
|
||||
|
||||
return {
|
||||
id,
|
||||
podcastId: feedId,
|
||||
title: item.trackName,
|
||||
description: item.description || item.shortDescription || "",
|
||||
audioUrl,
|
||||
duration: item.trackTimeMillis
|
||||
? Math.round(item.trackTimeMillis / 1000)
|
||||
: 0,
|
||||
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
||||
};
|
||||
})
|
||||
.filter((item): item is Episode => Boolean(item));
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ const makeResults = (query: string, source: PodcastSource, seedOffset = 0): Sear
|
||||
})
|
||||
}
|
||||
|
||||
export const searchRSSSource = async (
|
||||
const searchRSSSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
@@ -148,7 +148,7 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
|
||||
}
|
||||
}
|
||||
|
||||
export const searchAPISource = async (
|
||||
const searchAPISource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
@@ -173,7 +173,7 @@ export const searchAPISource = async (
|
||||
}))
|
||||
}
|
||||
|
||||
export const searchCustomSource = async (
|
||||
const searchCustomSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { SyncData } from "../types/sync-json"
|
||||
import type { SyncDataXML } from "../types/sync-xml"
|
||||
import { syncFormats } from "../constants/sync-formats"
|
||||
|
||||
const isObject = (value: unknown): value is { [key: string]: unknown } =>
|
||||
typeof value === "object" && value !== null
|
||||
|
||||
const hasVersion = (value: unknown): value is { version: string } =>
|
||||
isObject(value) && typeof value.version === "string"
|
||||
|
||||
export function validateJSONSync(data: unknown): SyncData {
|
||||
if (!hasVersion(data) || data.version !== syncFormats.json.version) {
|
||||
throw { message: "Unsupported sync format" }
|
||||
}
|
||||
|
||||
return data as SyncData
|
||||
}
|
||||
|
||||
export function validateXMLSync(data: unknown): SyncDataXML {
|
||||
if (!hasVersion(data) || data.version !== syncFormats.xml.version) {
|
||||
throw { message: "Unsupported sync format" }
|
||||
}
|
||||
|
||||
return data as SyncDataXML
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { SyncData } from "../types/sync-json"
|
||||
import type { SyncDataXML } from "../types/sync-xml"
|
||||
import { validateJSONSync, validateXMLSync } from "./sync-validation"
|
||||
import { syncFormats } from "../constants/sync-formats"
|
||||
import { FeedVisibility } from "../types/feed"
|
||||
|
||||
export function exportToJSON(data: SyncData): string {
|
||||
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}`
|
||||
}
|
||||
|
||||
export function importFromJSON(json: string): SyncData {
|
||||
const data = json
|
||||
return validateJSONSync(data as unknown)
|
||||
}
|
||||
|
||||
export function exportToXML(data: SyncDataXML): string {
|
||||
const feedItems = ""
|
||||
const sourceItems = ""
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<podcastSync version="${syncFormats.xml.version}">\n` +
|
||||
` <lastSyncedAt>${data.lastSyncedAt}</lastSyncedAt>\n` +
|
||||
` <feeds>\n` +
|
||||
feedItems +
|
||||
` </feeds>\n` +
|
||||
` <sources>\n` +
|
||||
sourceItems +
|
||||
` </sources>\n` +
|
||||
` <settings>\n` +
|
||||
` <theme>${data.settings.theme}</theme>\n` +
|
||||
` <playbackSpeed>${data.settings.playbackSpeed}</playbackSpeed>\n` +
|
||||
` <downloadPath>${data.settings.downloadPath}</downloadPath>\n` +
|
||||
` </settings>\n` +
|
||||
` <preferences>\n` +
|
||||
` <showExplicit>${data.preferences.showExplicit}</showExplicit>\n` +
|
||||
` <autoDownload>${data.preferences.autoDownload}</autoDownload>\n` +
|
||||
` </preferences>\n` +
|
||||
`</podcastSync>`
|
||||
}
|
||||
|
||||
export function importFromXML(xml: string): SyncDataXML {
|
||||
const version = syncFormats.xml.version
|
||||
const data = {
|
||||
version,
|
||||
lastSyncedAt: "",
|
||||
feeds: { feed: [] },
|
||||
sources: { source: [] },
|
||||
settings: {
|
||||
theme: "system",
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
},
|
||||
preferences: {
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
},
|
||||
} as SyncDataXML
|
||||
|
||||
return validateXMLSync(data)
|
||||
}
|
||||
@@ -13,15 +13,6 @@ export function clearPaletteCache() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export function detectSystemTheme(colors: TerminalColors) {
|
||||
const bg = RGBA.fromHex(
|
||||
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
|
||||
);
|
||||
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
|
||||
const mode = luminance > 0.5 ? "light" : "dark";
|
||||
return { mode, background: bg };
|
||||
}
|
||||
|
||||
export function generateSystemTheme(
|
||||
colors: TerminalColors,
|
||||
mode: "dark" | "light",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import type { ColorValue } from "../types/theme-schema"
|
||||
|
||||
const toCss = (value: ColorValue | RGBA) => {
|
||||
if (value instanceof RGBA) {
|
||||
const r = Math.round(value.r * 255)
|
||||
const g = Math.round(value.g * 255)
|
||||
const b = Math.round(value.b * 255)
|
||||
return `rgba(${r}, ${g}, ${b}, ${value.a})`
|
||||
}
|
||||
if (typeof value === "number") return `var(--ansi-${value})`
|
||||
if (typeof value === "string") return value
|
||||
return value.dark
|
||||
}
|
||||
|
||||
export function applyThemeToCSS(theme: Record<string, RGBA | ColorValue>) {
|
||||
const root = document.documentElement
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === "layerBackgrounds" && typeof value === "object") {
|
||||
const layers = value as Record<string, RGBA | ColorValue>
|
||||
for (const [layer, color] of Object.entries(layers)) {
|
||||
root.style.setProperty(`--color-${layer}`, toCss(color))
|
||||
}
|
||||
} else {
|
||||
root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute("data-theme", themeName)
|
||||
}
|
||||
|
||||
export function resolveColorReference(value: ColorValue) {
|
||||
return toCss(value)
|
||||
}
|
||||
@@ -1,42 +1,4 @@
|
||||
import path from "path"
|
||||
import type { ThemeJson } from "../types/theme-schema"
|
||||
import { THEME_JSON } from "../constants/themes"
|
||||
|
||||
export async function loadTheme(name: string) {
|
||||
if (THEME_JSON[name]) return THEME_JSON[name]
|
||||
const file = path.resolve(process.cwd(), "themes", `${name}.json`)
|
||||
return loadThemeFromPath(file)
|
||||
}
|
||||
|
||||
export async function loadThemeFromPath(file: string) {
|
||||
const json = (await Bun.file(file).json()) as ThemeJson
|
||||
validateTheme(json, file)
|
||||
return json
|
||||
}
|
||||
|
||||
export async function getAllThemes() {
|
||||
return { ...THEME_JSON, ...(await getCustomThemes()) }
|
||||
}
|
||||
|
||||
export async function getCustomThemes() {
|
||||
const dirs = [
|
||||
path.join(process.env.HOME ?? "", ".config/podtui/themes"),
|
||||
path.resolve(process.cwd(), ".podtui/themes"),
|
||||
path.resolve(process.cwd(), "themes"),
|
||||
]
|
||||
|
||||
const result: Record<string, ThemeJson> = {}
|
||||
for (const dir of dirs) {
|
||||
const glob = new Bun.Glob("*.json")
|
||||
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
|
||||
const name = path.basename(item, ".json")
|
||||
const json = (await Bun.file(item).json()) as ThemeJson
|
||||
validateTheme(json, item)
|
||||
result[name] = json
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function validateTheme(theme: ThemeJson, source?: string) {
|
||||
if (!theme || typeof theme !== "object") {
|
||||
|
||||
@@ -7,40 +7,12 @@
|
||||
* - Tracking theme change state
|
||||
*/
|
||||
|
||||
import { emit, on, off, type EventHandler } from "./event-bus"
|
||||
|
||||
/**
|
||||
* Subscribe to theme reload events.
|
||||
* These are triggered by SIGUSR2 signals.
|
||||
*/
|
||||
export function onThemeReload(handler: EventHandler<{}>): () => void {
|
||||
return on("theme.reload", handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to theme changed events.
|
||||
* These are triggered when the theme selection changes.
|
||||
*/
|
||||
export function onThemeChanged(
|
||||
handler: EventHandler<{ theme: string; mode: "dark" | "light" }>
|
||||
): () => void {
|
||||
return on("theme.changed", handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to theme mode changed events.
|
||||
* These are triggered when switching between dark/light mode.
|
||||
*/
|
||||
export function onThemeModeChanged(
|
||||
handler: EventHandler<{ mode: "dark" | "light" }>
|
||||
): () => void {
|
||||
return on("theme.mode.changed", handler)
|
||||
}
|
||||
import { emit } from "./event-bus"
|
||||
|
||||
/**
|
||||
* Emit a theme reload event.
|
||||
*/
|
||||
export function emitThemeReload(): void {
|
||||
function emitThemeReload(): void {
|
||||
emit("theme.reload", {})
|
||||
}
|
||||
|
||||
@@ -79,26 +51,3 @@ export function setupThemeSignalHandler(onReload: () => void): () => void {
|
||||
process.off("SIGUSR2", handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a debounced theme change handler to prevent rapid consecutive updates.
|
||||
*
|
||||
* @param handler - The handler to debounce
|
||||
* @param delay - Delay in milliseconds (default: 100ms)
|
||||
*/
|
||||
export function createDebouncedThemeHandler<T>(
|
||||
handler: (event: T) => void,
|
||||
delay: number = 100
|
||||
): (event: T) => void {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
|
||||
return (event: T) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
handler(event)
|
||||
timeout = null
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,67 +3,13 @@
|
||||
* Handles dynamic theme switching by updating CSS custom properties
|
||||
*/
|
||||
|
||||
import { RGBA, type TerminalColors } from "@opentui/core";
|
||||
import type { ThemeColors } from "../types/settings";
|
||||
import type { ColorValue, ThemeJson } from "../types/theme-schema";
|
||||
import type { TerminalColors } from "@opentui/core";
|
||||
import type { ThemeJson } from "../types/theme-schema";
|
||||
import { THEME_JSON } from "../constants/themes";
|
||||
import { getCustomThemes } from "./custom-themes";
|
||||
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
|
||||
import { generateSystemTheme } from "./system-theme";
|
||||
|
||||
const toCss = (value: ColorValue | RGBA) => {
|
||||
if (value instanceof RGBA) {
|
||||
const r = Math.round(value.r * 255);
|
||||
const g = Math.round(value.g * 255);
|
||||
const b = Math.round(value.b * 255);
|
||||
return `rgba(${r}, ${g}, ${b}, ${value.a})`;
|
||||
}
|
||||
if (typeof value === "number") return `var(--ansi-${value})`;
|
||||
if (typeof value === "string") return value;
|
||||
return value.dark;
|
||||
};
|
||||
|
||||
export function applyTheme(theme: ThemeColors | Record<string, RGBA>) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty(
|
||||
"--color-background",
|
||||
toCss(theme.background as ColorValue),
|
||||
);
|
||||
root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue));
|
||||
root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue));
|
||||
root.style.setProperty(
|
||||
"--color-secondary",
|
||||
toCss(theme.secondary as ColorValue),
|
||||
);
|
||||
root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue));
|
||||
root.style.setProperty("--color-text", toCss(theme.text as ColorValue));
|
||||
root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue));
|
||||
root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue));
|
||||
root.style.setProperty("--color-error", toCss(theme.error as ColorValue));
|
||||
root.style.setProperty("--color-success", toCss(theme.success as ColorValue));
|
||||
|
||||
const layers = theme.layerBackgrounds as
|
||||
| Record<string, ColorValue>
|
||||
| undefined;
|
||||
if (layers) {
|
||||
root.style.setProperty("--color-layer0", toCss(layers.layer0));
|
||||
root.style.setProperty("--color-layer1", toCss(layers.layer1));
|
||||
root.style.setProperty("--color-layer2", toCss(layers.layer2));
|
||||
root.style.setProperty("--color-layer3", toCss(layers.layer3));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme mode from system preference
|
||||
*/
|
||||
export function getSystemThemeMode(): "dark" | "light" {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
return prefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CSS variable data-theme attribute
|
||||
*/
|
||||
@@ -77,15 +23,6 @@ export async function loadThemes() {
|
||||
return await getCustomThemes();
|
||||
}
|
||||
|
||||
export async function loadTheme(name: string) {
|
||||
const themes = await loadThemes();
|
||||
return themes[name];
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
return resolveThemeJson(theme, mode);
|
||||
}
|
||||
|
||||
export function resolveTerminalTheme(
|
||||
themes: Record<string, ThemeJson>,
|
||||
name: string,
|
||||
|
||||
Reference in New Issue
Block a user