start revive

This commit is contained in:
2026-07-30 21:25:03 -04:00
parent b7c4938c54
commit d8f11040bc
25 changed files with 5344 additions and 2559 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -20,128 +20,148 @@
* ```
*/
type EventHandler<T = unknown> = (data: T) => void
type EventHandler<T = unknown> = (data: T) => void;
// Export EventHandler type for external use
export type { EventHandler }
export type { EventHandler };
interface EventBusInstance {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void
once<T = unknown>(event: string, handler: EventHandler<T>): () => void
off<T = unknown>(event: string, handler: EventHandler<T>): void
emit<T = unknown>(event: string, data: T): void
clear(): void
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
off<T = unknown>(event: string, handler: EventHandler<T>): void;
emit<T = unknown>(event: string, data: T): void;
clear(): void;
}
function createEventBus(): EventBusInstance {
const handlers = new Map<string, Set<EventHandler>>()
const handlers = new Map<string, Set<EventHandler>>();
return {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!handlers.has(event)) {
handlers.set(event, new Set())
}
handlers.get(event)!.add(handler as EventHandler)
return {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!handlers.has(event)) {
handlers.set(event, new Set());
}
handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.off(event, handler)
}
},
// Return unsubscribe function
return () => {
this.off(event, handler);
};
},
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
const wrappedHandler: EventHandler<T> = (data) => {
this.off(event, wrappedHandler)
handler(data)
}
return this.on(event, wrappedHandler)
},
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
const wrappedHandler: EventHandler<T> = (data) => {
this.off(event, wrappedHandler);
handler(data);
};
return this.on(event, wrappedHandler);
},
off<T = unknown>(event: string, handler: EventHandler<T>): void {
const eventHandlers = handlers.get(event)
if (eventHandlers) {
eventHandlers.delete(handler as EventHandler)
if (eventHandlers.size === 0) {
handlers.delete(event)
}
}
},
off<T = unknown>(event: string, handler: EventHandler<T>): void {
const eventHandlers = handlers.get(event);
if (eventHandlers) {
eventHandlers.delete(handler as EventHandler);
if (eventHandlers.size === 0) {
handlers.delete(event);
}
}
},
emit<T = unknown>(event: string, data: T): void {
const eventHandlers = handlers.get(event)
if (eventHandlers) {
for (const handler of eventHandlers) {
try {
handler(data)
} catch (error) {
console.error(`Error in event handler for "${event}":`, error)
}
}
}
},
emit<T = unknown>(event: string, data: T): void {
const eventHandlers = handlers.get(event);
if (eventHandlers) {
for (const handler of eventHandlers) {
try {
handler(data);
} catch (error) {
console.error(`Error in event handler for "${event}":`, error);
}
}
}
},
clear(): void {
handlers.clear()
},
}
clear(): void {
handlers.clear();
},
};
}
// Singleton event bus instance
export const EventBus = createEventBus()
export const EventBus = createEventBus();
import type { KeybindActionName } from "@/context/KeybindContext";
import type { TABS } from "@/utils/navigation";
import type { PaneId, NavMode } from "@/context/NavigationContext";
// Common event types for the application
export type AppEvents = {
"theme.changed": { theme: string; mode: "dark" | "light" }
"theme.mode.changed": { mode: "dark" | "light" }
"theme.reload": {}
"navigation.tab.changed": { tab: string; previousTab?: string }
"navigation.layer.changed": { depth: number; previousDepth: number }
"feed.subscribed": { feedId: string; feedUrl: string }
"feed.unsubscribed": { feedId: string }
"player.play": { episodeId: string }
"player.pause": { episodeId: string }
"player.stop": {}
"auth.login": { userId: string }
"auth.logout": {}
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number }
"dialog.open": { dialogId: string }
"dialog.close": { dialogId?: string }
"command.execute": { command: string; args?: unknown }
"clipboard.copied": { text: string }
"selection.start": { x: number; y: number }
"selection.end": { text: string }
"theme.changed": { theme: string; mode: "dark" | "light" };
"theme.mode.changed": { mode: "dark" | "light" };
"theme.reload": {};
"navigation.tab.changed": { tab: string; previousTab?: string };
"navigation.layer.changed": { depth: number; previousDepth: number };
"feed.subscribed": { feedId: string; feedUrl: string };
"feed.unsubscribed": { feedId: string };
"player.play": { episodeId: string };
"player.pause": { episodeId: string };
"player.stop": {};
"auth.login": { userId: string };
"auth.logout": {};
"toast.show": {
message: string;
variant: "info" | "success" | "warning" | "error";
title?: string;
duration?: number;
};
"dialog.open": { dialogId: string };
"dialog.close": { dialogId?: string };
"command.execute": { command: string; args?: unknown };
// Yazi-style unified router → active page dispatch. The Shell router
// emits these; each page subscribes to the subset it implements.
"nav.action": {
action: KeybindActionName;
tab: TABS;
pane: PaneId;
mode: NavMode;
};
"clipboard.copied": { text: string };
"selection.start": { x: number; y: number };
"selection.end": { text: string };
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
"media.toggle": {}
"media.volumeUp": {}
"media.volumeDown": {}
"media.seekForward": {}
"media.seekBackward": {}
"media.speedCycle": {}
}
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
"media.toggle": {};
"media.volumeUp": {};
"media.volumeDown": {};
"media.seekForward": {};
"media.seekBackward": {};
"media.speedCycle": {};
};
// Type-safe emit and on functions
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void {
EventBus.emit(event, data)
export function emit<K extends keyof AppEvents>(
event: K,
data: AppEvents[K],
): void {
EventBus.emit(event, data);
}
export function on<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
event: K,
handler: EventHandler<AppEvents[K]>,
): () => void {
return EventBus.on(event, handler)
return EventBus.on(event, handler);
}
export function once<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
event: K,
handler: EventHandler<AppEvents[K]>,
): () => void {
return EventBus.once(event, handler)
return EventBus.once(event, handler);
}
export function off<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
event: K,
handler: EventHandler<AppEvents[K]>,
): void {
EventBus.off(event, handler)
EventBus.off(event, handler);
}

View File

@@ -1,90 +1,121 @@
/**
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME
*
* Handles copying keybind.jsonc from package to user config directory
* Handles copying keybinds.jsonc from package to user config directory
* and loading/saving keybind configurations.
*/
import { copyFile, mkdir } from "fs/promises";
import { copyFile } from "fs/promises";
import path from "path";
import { parseJSONC } from "./jsonc";
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
import type { KeybindsResolved } from "../context/KeybindContext";
const KEYBINDS_SOURCE = path.join(
process.cwd(),
"src",
"config",
"keybind.jsonc",
process.cwd(),
"src",
"config",
"keybinds.jsonc",
);
const KEYBINDS_FILE = "keybinds.jsonc";
/** Default keybinds from package */
/** Default keybinds (yazi-style) — mirrors src/config/keybinds.jsonc so the
* app works before a user keybinds file is copied into place. */
const DEFAULT_KEYBINDS: KeybindsResolved = {
up: ["up", "k"],
down: ["down", "j"],
left: ["left", "h"],
right: ["right", "l"],
cycle: ["tab"],
dive: ["return"],
select: ["return"],
out: ["esc"],
inverseModifier: "shift",
leader: ":",
quit: ["<leader>q"],
"audio-toggle": ["<leader>p"],
"audio-pause": [],
"audio-play": [],
"audio-next": ["<leader>n"],
"audio-prev": ["<leader>l"],
"audio-seek-forward": ["<leader>sf"],
"audio-seek-backward": ["<leader>sb"],
// movement
"move-down": ["j", "down"],
"move-up": ["k", "up"],
"page-down": ["ctrl-d"],
"page-up": ["ctrl-u"],
"full-down": ["ctrl-f"],
"full-up": ["ctrl-b"],
"jump-down": ["J"],
"jump-up": ["K"],
"goto-top": [["g", "g"]],
"goto-bottom": ["G"],
// pane swipe
"swipe-prev": ["h", "left"],
"swipe-next": ["l", "right"],
// open / select
open: ["return", "enter"],
"open-interactive": ["shift-return"],
"toggle-select": ["space"],
"visual-mode": ["v"],
"toggle-all": ["ctrl-a"],
"invert-all": ["ctrl-r"],
escape: ["escape", "ctrl-["],
// tabs
"tab-prev": ["["],
"tab-next": ["]"],
"tab-goto-1": ["1"],
"tab-goto-2": ["2"],
"tab-goto-3": ["3"],
"tab-goto-4": ["4"],
"tab-goto-5": ["5"],
"tab-goto-6": ["6"],
// command / help / quit
command: [":"],
quit: ["q", "ctrl-c"],
help: ["~", "f1"],
// list ops
search: ["s"],
filter: ["f"],
sort: [","],
"toggle-hidden": ["."],
refresh: ["r"],
// audio transport (preserved; shifted single keys, no collisions)
"audio-toggle": ["P"],
"audio-next": ["N"],
"audio-prev": ["B"],
"audio-seek-forward": ["shift-."],
"audio-seek-backward": ["shift-,"],
};
/** Copy keybind.jsonc to user config directory on first run */
/** Copy keybinds.jsonc to user config directory on first run */
export async function copyKeybindsIfNeeded(): Promise<void> {
try {
const targetPath = getConfigFilePath(KEYBINDS_FILE);
try {
const targetPath = getConfigFilePath(KEYBINDS_FILE);
// Check if file already exists
const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return;
// Check if file already exists
const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return;
await ensureConfigDir();
await copyFile(KEYBINDS_SOURCE, targetPath);
} catch {
// Silently ignore errors
}
await ensureConfigDir();
await copyFile(KEYBINDS_SOURCE, targetPath);
} catch {
// Silently ignore errors
}
}
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);
const file = Bun.file(filePath);
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return DEFAULT_KEYBINDS;
if (!(await file.exists())) return DEFAULT_KEYBINDS;
const raw = await file.text();
const parsed = parseJSONC(raw);
const raw = await file.text();
const parsed = parseJSONC(raw);
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
} catch {
return DEFAULT_KEYBINDS;
}
// Merge so partial user configs inherit defaults for missing keys.
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
} catch {
return DEFAULT_KEYBINDS;
}
}
/** Save keybinds to JSONC file */
export async function saveKeybindsToFile(
keybinds: KeybindsResolved,
keybinds: KeybindsResolved,
): Promise<void> {
try {
await ensureConfigDir();
const filePath = getConfigFilePath(KEYBINDS_FILE);
await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
} catch {
// Silently ignore write errors
}
try {
await ensureConfigDir();
const filePath = getConfigFilePath(KEYBINDS_FILE);
await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
} catch {
// Silently ignore write errors
}
}

View File

@@ -6,33 +6,55 @@ import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
export enum DIRECTION {
Increment,
Decrement,
Increment,
Decrement,
}
export enum TABS {
FEED = 1,
MYSHOWS = 2,
DISCOVER = 3,
SEARCH = 4,
PLAYER = 5,
SETTINGS = 6,
FEED = 1,
MYSHOWS = 2,
DISCOVER = 3,
SEARCH = 4,
PLAYER = 5,
SETTINGS = 6,
}
export const TabsCount = 6;
export const LayerGraph = {
[TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage,
[TABS.DISCOVER]: DiscoverPage,
[TABS.SEARCH]: SearchPage,
[TABS.PLAYER]: PlayerPage,
[TABS.SETTINGS]: SettingsPage,
[TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage,
[TABS.DISCOVER]: DiscoverPage,
[TABS.SEARCH]: SearchPage,
[TABS.PLAYER]: PlayerPage,
[TABS.SETTINGS]: SettingsPage,
};
export const LayerDepths = {
[TABS.FEED]: FeedPaneCount,
[TABS.MYSHOWS]: MyShowsPaneCount,
[TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount,
[TABS.SETTINGS]: SettingsPaneCount,
[TABS.FEED]: FeedPaneCount,
[TABS.MYSHOWS]: MyShowsPaneCount,
[TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount,
[TABS.SETTINGS]: SettingsPaneCount,
};
// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3].
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
// of terminal size — more robust than fixed percentages and exactly mirrors
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
export const PANE_RATIO = {
parent: 1,
current: 4,
preview: 3,
} as const;
// Number of interactive panes per tab (for the yazi h/l swipe). Slots beyond
// a tab's count are not focusable. Defined here (after TABS) to avoid re-introducing
// the old NavigationContext top-level-init circular deadlock.
export const TabPaneCount: Record<TABS, number> = {
[TABS.FEED]: 3, // feeds | episodes | preview
[TABS.MYSHOWS]: 3, // shows | episodes | preview
[TABS.DISCOVER]: 3, // categories | results | detail
[TABS.SEARCH]: 3, // query | results | detail
[TABS.PLAYER]: 1, // single pane
[TABS.SETTINGS]: 2, // sections | panel
};

View File

@@ -1,156 +1,174 @@
import { searchSourceByType } from "./source-searcher"
import type { PodcastSource, SearchResult } from "../types/source"
import type { Episode } from "../types/episode"
import { searchSourceByType } from "./source-searcher";
import type { PodcastSource, SearchResult } from "../types/source";
import type { Episode } from "../types/episode";
type SearchCacheEntry = {
timestamp: number
results: SearchResult[]
}
timestamp: number;
results: SearchResult[];
};
type SearchOptions = {
cacheTtl?: number
}
cacheTtl?: number;
};
const searchCache = new Map<string, SearchCacheEntry>()
const rateLimitState = new Map<string, number[]>()
const RATE_LIMIT_WINDOW_MS = 60000
const RATE_LIMIT_MAX_CALLS = 20
const searchCache = new Map<string, SearchCacheEntry>();
const rateLimitState = new Map<string, number[]>();
const RATE_LIMIT_WINDOW_MS = 60000;
const RATE_LIMIT_MAX_CALLS = 20;
const throttleSource = async (sourceId: string) => {
const now = Date.now()
const windowStart = now - RATE_LIMIT_WINDOW_MS
const timestamps = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
const now = Date.now();
const windowStart = now - RATE_LIMIT_WINDOW_MS;
const timestamps =
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs))
}
}
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
const updated = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
updated.push(Date.now())
rateLimitState.set(sourceId, updated)
}
const updated =
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
updated.push(Date.now());
rateLimitState.set(sourceId, updated);
};
const buildCacheKey = (query: string, sourceIds: string[]) => {
const keySources = [...sourceIds].sort().join(",")
return `${query.toLowerCase()}::${keySources}`
}
const keySources = [...sourceIds].sort().join(",");
return `${query.toLowerCase()}::${keySources}`;
};
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
Date.now() - entry.timestamp < ttl
Date.now() - entry.timestamp < ttl;
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
const map = new Map<string, SearchResult>()
for (const result of results) {
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title
const existing = map.get(key)
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
map.set(key, result)
}
}
return Array.from(map.values())
}
const map = new Map<string, SearchResult>();
for (const result of results) {
const key =
result.podcast.feedUrl || result.podcast.id || result.podcast.title;
const existing = map.get(key);
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
map.set(key, result);
}
}
return Array.from(map.values());
};
export const searchPodcasts = async (
query: string,
sourceIds: string[],
sources: PodcastSource[],
options: SearchOptions = {}
query: string,
sourceIds: string[],
sources: PodcastSource[],
options: SearchOptions = {},
): Promise<SearchResult[]> => {
const trimmed = query.trim()
if (!trimmed) return []
const trimmed = query.trim();
if (!trimmed) return [];
const activeSources = sources.filter(
(source) => sourceIds.includes(source.id) && source.enabled
)
const activeSources = sources.filter(
(source) => sourceIds.includes(source.id) && source.enabled,
);
if (activeSources.length === 0) return []
if (activeSources.length === 0) {
// No enabled sources — surface a clear cause instead of returning empty,
// which otherwise looks indistinguishable from a network failure.
if (sourceIds.length === 0) {
throw new Error("No search sources are enabled");
}
throw new Error("No enabled sources match the selected search sources");
}
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id))
const cached = searchCache.get(cacheKey)
if (cached && isCacheValid(cached, cacheTtl)) {
return cached.results
}
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5;
const cacheKey = buildCacheKey(
trimmed,
activeSources.map((s) => s.id),
);
const cached = searchCache.get(cacheKey);
if (cached && isCacheValid(cached, cacheTtl)) {
return cached.results;
}
const results: SearchResult[] = []
const errors: Error[] = []
const results: SearchResult[] = [];
const errors: Error[] = [];
await Promise.all(
activeSources.map(async (source) => {
try {
await throttleSource(source.id)
const sourceResults = await searchSourceByType(trimmed, source)
results.push(...sourceResults)
} catch (error) {
errors.push(error as Error)
}
})
)
await Promise.all(
activeSources.map(async (source) => {
try {
await throttleSource(source.id);
const sourceResults = await searchSourceByType(trimmed, source);
results.push(...sourceResults);
} catch (error) {
errors.push(error as Error);
}
}),
);
const deduped = dedupeResults(results)
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
const deduped = dedupeResults(results);
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
if (sorted.length === 0 && errors.length > 0) {
throw new Error("Search failed for all sources")
}
if (sorted.length === 0 && errors.length > 0) {
throw new Error("Search failed for all sources");
}
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted })
return sorted
}
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted });
return sorted;
};
type ItunesEpisodeResult = {
trackId?: number
trackName?: string
description?: string
shortDescription?: string
releaseDate?: string
trackTimeMillis?: number
episodeUrl?: string
previewUrl?: string
trackViewUrl?: string
}
trackId?: number;
trackName?: string;
description?: string;
shortDescription?: string;
releaseDate?: string;
trackTimeMillis?: number;
episodeUrl?: string;
previewUrl?: string;
trackViewUrl?: string;
};
type ItunesEpisodeResponse = {
resultCount: number
results: ItunesEpisodeResult[]
}
resultCount: number;
results: ItunesEpisodeResult[];
};
export const searchEpisodes = async (
query: string,
feedId: string
query: string,
feedId: string,
): Promise<Episode[]> => {
const trimmed = query.trim()
if (!trimmed) return []
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 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 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 || ""
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))
}
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));
};