docs: add human-oriented CONTRIBUTING.md (repo map, FFI notes, gotchas, release & tap auto-sync workflow)
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
onCleanup,
|
||||
} from "solid-js";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
@@ -44,6 +45,7 @@ export const SearchPaneCount = 1;
|
||||
|
||||
function SearchPage() {
|
||||
const searchStore = useSearchStore();
|
||||
const feedStore = useFeedStore();
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
@@ -127,6 +129,8 @@ function SearchPage() {
|
||||
};
|
||||
|
||||
const handleSubscribe = (result: SearchResult) => {
|
||||
// Actually add the feed to the feed store, then mark the result subscribed
|
||||
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
|
||||
searchStore.markSubscribed(result.podcast.id);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,213 +3,233 @@
|
||||
* Manages trending/popular podcasts and category filtering
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { createSignal } from "solid-js";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
import { useFeedStore } from "./feed";
|
||||
|
||||
export interface DiscoverCategory {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||
{ id: "all", name: "All", icon: "*" },
|
||||
{ id: "technology", name: "Technology", icon: ">" },
|
||||
{ id: "science", name: "Science", icon: "~" },
|
||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||
{ id: "news", name: "News", icon: "!" },
|
||||
{ id: "business", name: "Business", icon: "$" },
|
||||
{ id: "health", name: "Health", icon: "+" },
|
||||
{ id: "education", name: "Education", icon: "?" },
|
||||
{ id: "sports", name: "Sports", icon: "#" },
|
||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||
{ id: "arts", name: "Arts", icon: "@" },
|
||||
]
|
||||
{ id: "all", name: "All", icon: "*" },
|
||||
{ id: "technology", name: "Technology", icon: ">" },
|
||||
{ id: "science", name: "Science", icon: "~" },
|
||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||
{ id: "news", name: "News", icon: "!" },
|
||||
{ id: "business", name: "Business", icon: "$" },
|
||||
{ id: "health", name: "Health", icon: "+" },
|
||||
{ id: "education", name: "Education", icon: "?" },
|
||||
{ id: "sports", name: "Sports", icon: "#" },
|
||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||
{ id: "arts", name: "Arts", icon: "@" },
|
||||
];
|
||||
|
||||
/** Mock trending podcasts */
|
||||
const TRENDING_PODCASTS: Podcast[] = [
|
||||
{
|
||||
id: "trend-1",
|
||||
title: "AI Today",
|
||||
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
||||
feedUrl: "https://example.com/aitoday.rss",
|
||||
author: "Tech Futures",
|
||||
categories: ["Technology", "Science"],
|
||||
coverUrl: undefined,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-2",
|
||||
title: "The History Hour",
|
||||
description: "Fascinating stories from history that shaped our world today.",
|
||||
feedUrl: "https://example.com/historyhour.rss",
|
||||
author: "History Channel",
|
||||
categories: ["Education", "History"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-3",
|
||||
title: "Comedy Gold",
|
||||
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
|
||||
feedUrl: "https://example.com/comedygold.rss",
|
||||
author: "Laugh Factory",
|
||||
categories: ["Comedy", "Entertainment"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-4",
|
||||
title: "Market Watch",
|
||||
description: "Daily financial news, stock analysis, and investing tips.",
|
||||
feedUrl: "https://example.com/marketwatch.rss",
|
||||
author: "Finance Daily",
|
||||
categories: ["Business", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
{
|
||||
id: "trend-5",
|
||||
title: "Science Weekly",
|
||||
description: "Breaking science news and in-depth analysis of the latest research.",
|
||||
feedUrl: "https://example.com/scienceweekly.rss",
|
||||
author: "Science Network",
|
||||
categories: ["Science", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-6",
|
||||
title: "True Crime Files",
|
||||
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
|
||||
feedUrl: "https://example.com/truecrime.rss",
|
||||
author: "Crime Network",
|
||||
categories: ["True Crime", "Documentary"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-7",
|
||||
title: "Wellness Journey",
|
||||
description: "Tips for mental and physical health, meditation, and mindful living.",
|
||||
feedUrl: "https://example.com/wellness.rss",
|
||||
author: "Health Media",
|
||||
categories: ["Health", "Self-Help"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-8",
|
||||
title: "Sports Talk Live",
|
||||
description: "Live commentary, analysis, and interviews from the world of sports.",
|
||||
feedUrl: "https://example.com/sportstalk.rss",
|
||||
author: "Sports Network",
|
||||
categories: ["Sports", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-9",
|
||||
title: "Creative Minds",
|
||||
description: "Interviews with artists, designers, and creative professionals.",
|
||||
feedUrl: "https://example.com/creativeminds.rss",
|
||||
author: "Arts Weekly",
|
||||
categories: ["Arts", "Culture"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-10",
|
||||
title: "Dev Talk",
|
||||
description: "Software development, programming tutorials, and tech career advice.",
|
||||
feedUrl: "https://example.com/devtalk.rss",
|
||||
author: "Code Academy",
|
||||
categories: ["Technology", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "trend-1",
|
||||
title: "AI Today",
|
||||
description:
|
||||
"The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
||||
feedUrl: "https://example.com/aitoday.rss",
|
||||
author: "Tech Futures",
|
||||
categories: ["Technology", "Science"],
|
||||
coverUrl: undefined,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-2",
|
||||
title: "The History Hour",
|
||||
description:
|
||||
"Fascinating stories from history that shaped our world today.",
|
||||
feedUrl: "https://example.com/historyhour.rss",
|
||||
author: "History Channel",
|
||||
categories: ["Education", "History"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-3",
|
||||
title: "Comedy Gold",
|
||||
description:
|
||||
"Weekly stand-up comedy, sketches, and hilarious conversations.",
|
||||
feedUrl: "https://example.com/comedygold.rss",
|
||||
author: "Laugh Factory",
|
||||
categories: ["Comedy", "Entertainment"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-4",
|
||||
title: "Market Watch",
|
||||
description: "Daily financial news, stock analysis, and investing tips.",
|
||||
feedUrl: "https://example.com/marketwatch.rss",
|
||||
author: "Finance Daily",
|
||||
categories: ["Business", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
{
|
||||
id: "trend-5",
|
||||
title: "Science Weekly",
|
||||
description:
|
||||
"Breaking science news and in-depth analysis of the latest research.",
|
||||
feedUrl: "https://example.com/scienceweekly.rss",
|
||||
author: "Science Network",
|
||||
categories: ["Science", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-6",
|
||||
title: "True Crime Files",
|
||||
description:
|
||||
"Investigative journalism into real criminal cases and unsolved mysteries.",
|
||||
feedUrl: "https://example.com/truecrime.rss",
|
||||
author: "Crime Network",
|
||||
categories: ["True Crime", "Documentary"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-7",
|
||||
title: "Wellness Journey",
|
||||
description:
|
||||
"Tips for mental and physical health, meditation, and mindful living.",
|
||||
feedUrl: "https://example.com/wellness.rss",
|
||||
author: "Health Media",
|
||||
categories: ["Health", "Self-Help"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-8",
|
||||
title: "Sports Talk Live",
|
||||
description:
|
||||
"Live commentary, analysis, and interviews from the world of sports.",
|
||||
feedUrl: "https://example.com/sportstalk.rss",
|
||||
author: "Sports Network",
|
||||
categories: ["Sports", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-9",
|
||||
title: "Creative Minds",
|
||||
description:
|
||||
"Interviews with artists, designers, and creative professionals.",
|
||||
feedUrl: "https://example.com/creativeminds.rss",
|
||||
author: "Arts Weekly",
|
||||
categories: ["Arts", "Culture"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-10",
|
||||
title: "Dev Talk",
|
||||
description:
|
||||
"Software development, programming tutorials, and tech career advice.",
|
||||
feedUrl: "https://example.com/devtalk.rss",
|
||||
author: "Code Academy",
|
||||
categories: ["Technology", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Create discover store */
|
||||
export function createDiscoverStore() {
|
||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all")
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS)
|
||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS);
|
||||
|
||||
/** Get filtered podcasts by category */
|
||||
const filteredPodcasts = () => {
|
||||
const category = selectedCategory()
|
||||
if (category === "all") {
|
||||
return podcasts()
|
||||
}
|
||||
/** Get filtered podcasts by category */
|
||||
const filteredPodcasts = () => {
|
||||
const category = selectedCategory();
|
||||
if (category === "all") {
|
||||
return podcasts();
|
||||
}
|
||||
|
||||
return podcasts().filter((p) => {
|
||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
||||
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " ")))
|
||||
})
|
||||
}
|
||||
return podcasts().filter((p) => {
|
||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
|
||||
return cats.some((c) =>
|
||||
c.includes(category.toLowerCase().replace("-", " ")),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/** Subscribe to a podcast */
|
||||
const subscribe = (podcastId: string) => {
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === podcastId ? { ...p, isSubscribed: true } : p
|
||||
)
|
||||
)
|
||||
}
|
||||
/** Subscribe to a podcast */
|
||||
const subscribe = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast) {
|
||||
// Actually add the feed to the feed store
|
||||
const feedStore = useFeedStore();
|
||||
feedStore.addFeed(podcast, "discover").catch(() => {});
|
||||
}
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: true } : p)),
|
||||
);
|
||||
};
|
||||
|
||||
/** Unsubscribe from a podcast */
|
||||
const unsubscribe = (podcastId: string) => {
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === podcastId ? { ...p, isSubscribed: false } : p
|
||||
)
|
||||
)
|
||||
}
|
||||
/** Unsubscribe from a podcast */
|
||||
const unsubscribe = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast) {
|
||||
// Remove the feed from the feed store
|
||||
const feedStore = useFeedStore();
|
||||
feedStore.removeFeedByUrl(podcast.feedUrl);
|
||||
}
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: false } : p)),
|
||||
);
|
||||
};
|
||||
|
||||
/** Toggle subscription */
|
||||
const toggleSubscription = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId)
|
||||
if (podcast?.isSubscribed) {
|
||||
unsubscribe(podcastId)
|
||||
} else {
|
||||
subscribe(podcastId)
|
||||
}
|
||||
}
|
||||
/** Toggle subscription */
|
||||
const toggleSubscription = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast?.isSubscribed) {
|
||||
unsubscribe(podcastId);
|
||||
} else {
|
||||
subscribe(podcastId);
|
||||
}
|
||||
};
|
||||
|
||||
/** Refresh trending podcasts (mock) */
|
||||
const refresh = async () => {
|
||||
setIsLoading(true)
|
||||
// Simulate network delay
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
// In real app, would fetch from API
|
||||
setIsLoading(false)
|
||||
}
|
||||
/** Refresh trending podcasts (mock) */
|
||||
const refresh = async () => {
|
||||
setIsLoading(true);
|
||||
// Simulate network delay
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
// In real app, would fetch from API
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedCategory,
|
||||
isLoading,
|
||||
podcasts,
|
||||
filteredPodcasts,
|
||||
categories: DISCOVER_CATEGORIES,
|
||||
return {
|
||||
// State
|
||||
selectedCategory,
|
||||
isLoading,
|
||||
podcasts,
|
||||
filteredPodcasts,
|
||||
categories: DISCOVER_CATEGORIES,
|
||||
|
||||
// Actions
|
||||
setSelectedCategory,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
toggleSubscription,
|
||||
refresh,
|
||||
}
|
||||
// Actions
|
||||
setSelectedCategory,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
toggleSubscription,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton discover store */
|
||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null
|
||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
||||
|
||||
export function useDiscoverStore() {
|
||||
if (!discoverStoreInstance) {
|
||||
discoverStoreInstance = createDiscoverStore()
|
||||
}
|
||||
return discoverStoreInstance
|
||||
if (!discoverStoreInstance) {
|
||||
discoverStoreInstance = createDiscoverStore();
|
||||
}
|
||||
return discoverStoreInstance;
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import { createSignal } from "solid-js";
|
||||
import { FeedVisibility } from "../types/feed";
|
||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
import type { Episode, EpisodeStatus } from "../types/episode";
|
||||
import type { PodcastSource, SourceType } from "../types/source";
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { PodcastSource } from "../types/source";
|
||||
import { DEFAULT_SOURCES } from "../types/source";
|
||||
import { parseRSSFeed } from "../api/rss-parser";
|
||||
import {
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
loadSourcesFromFile,
|
||||
saveSourcesToFile,
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
loadSourcesFromFile,
|
||||
saveSourcesToFile,
|
||||
} from "../utils/feeds-persistence";
|
||||
import { useDownloadStore } from "./download";
|
||||
import { DownloadStatus } from "../types/episode";
|
||||
@@ -35,461 +35,491 @@ const episodeLoadCount = new Map<string, number>();
|
||||
|
||||
/** Save feeds to file (async, fire-and-forget) */
|
||||
function saveFeeds(feeds: Feed[]): void {
|
||||
saveFeedsToFile(feeds).catch(() => {});
|
||||
saveFeedsToFile(feeds).catch(() => {});
|
||||
}
|
||||
|
||||
/** Save sources to file (async, fire-and-forget) */
|
||||
function saveSources(sources: PodcastSource[]): void {
|
||||
saveSourcesToFile(sources).catch(() => {});
|
||||
saveSourcesToFile(sources).catch(() => {});
|
||||
}
|
||||
|
||||
/** Create feed store */
|
||||
export function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||
...DEFAULT_SOURCES,
|
||||
]);
|
||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||
visibility: "all",
|
||||
sortBy: "updated" as FeedSortField,
|
||||
sortDirection: "desc",
|
||||
});
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||
...DEFAULT_SOURCES,
|
||||
]);
|
||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||
visibility: "all",
|
||||
sortBy: "updated" as FeedSortField,
|
||||
sortDirection: "desc",
|
||||
});
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||
|
||||
/** Get filtered and sorted feeds */
|
||||
const getFilteredFeeds = (): Feed[] => {
|
||||
let result = [...feeds()];
|
||||
const f = filter();
|
||||
const authStore = useAuthStore();
|
||||
/** Get filtered and sorted feeds */
|
||||
const getFilteredFeeds = (): Feed[] => {
|
||||
let result = [...feeds()];
|
||||
const f = filter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Filter by visibility
|
||||
if (f.visibility && f.visibility !== "all") {
|
||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||
} else if (f.visibility === "all") {
|
||||
// Only show private feeds if authenticated
|
||||
result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated);
|
||||
}
|
||||
// Filter by visibility
|
||||
if (f.visibility && f.visibility !== "all") {
|
||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||
} else if (f.visibility === "all") {
|
||||
// Only show private feeds if authenticated
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.visibility === FeedVisibility.PUBLIC ||
|
||||
authStore.isAuthenticated,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by source
|
||||
if (f.sourceId) {
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||
}
|
||||
// Filter by source
|
||||
if (f.sourceId) {
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||
}
|
||||
|
||||
// Filter by pinned
|
||||
if (f.pinnedOnly) {
|
||||
result = result.filter((feed) => feed.isPinned);
|
||||
}
|
||||
// Filter by pinned
|
||||
if (f.pinnedOnly) {
|
||||
result = result.filter((feed) => feed.isPinned);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (f.searchQuery) {
|
||||
const query = f.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(query) ||
|
||||
feed.customName?.toLowerCase().includes(query) ||
|
||||
feed.podcast.description?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
// Filter by search query
|
||||
if (f.searchQuery) {
|
||||
const query = f.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(query) ||
|
||||
feed.customName?.toLowerCase().includes(query) ||
|
||||
feed.podcast.description?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by selected field
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||
result.sort((a, b) => {
|
||||
switch (f.sortBy) {
|
||||
case "title":
|
||||
return (
|
||||
sortDir *
|
||||
(a.customName || a.podcast.title).localeCompare(
|
||||
b.customName || b.podcast.title,
|
||||
)
|
||||
);
|
||||
case "episodeCount":
|
||||
return sortDir * (a.episodes.length - b.episodes.length);
|
||||
case "latestEpisode":
|
||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
||||
return sortDir * (aLatest - bLatest);
|
||||
case "updated":
|
||||
default:
|
||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
||||
}
|
||||
});
|
||||
// Sort by selected field
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||
result.sort((a, b) => {
|
||||
switch (f.sortBy) {
|
||||
case "title":
|
||||
return (
|
||||
sortDir *
|
||||
(a.customName || a.podcast.title).localeCompare(
|
||||
b.customName || b.podcast.title,
|
||||
)
|
||||
);
|
||||
case "episodeCount":
|
||||
return sortDir * (a.episodes.length - b.episodes.length);
|
||||
case "latestEpisode":
|
||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
||||
return sortDir * (aLatest - bLatest);
|
||||
case "updated":
|
||||
default:
|
||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
// Pinned feeds always first
|
||||
result.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
// Pinned feeds always first
|
||||
result.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Get episodes in reverse chronological order across all feeds */
|
||||
const getAllEpisodesChronological = (): Array<{
|
||||
episode: Episode;
|
||||
feed: Feed;
|
||||
}> => {
|
||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
/** Get episodes in reverse chronological order across all feeds */
|
||||
const getAllEpisodesChronological = (): Array<{
|
||||
episode: Episode;
|
||||
feed: Feed;
|
||||
}> => {
|
||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
for (const feed of feeds()) {
|
||||
for (const episode of feed.episodes) {
|
||||
allEpisodes.push({ episode, feed });
|
||||
}
|
||||
}
|
||||
for (const feed of feeds()) {
|
||||
for (const episode of feed.episodes) {
|
||||
allEpisodes.push({ episode, feed });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by publication date (newest first)
|
||||
allEpisodes.sort(
|
||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
||||
);
|
||||
// Sort by publication date (newest first)
|
||||
allEpisodes.sort(
|
||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
||||
);
|
||||
|
||||
return allEpisodes;
|
||||
};
|
||||
return allEpisodes;
|
||||
};
|
||||
|
||||
/** Sort episodes in reverse chronological order (newest first) */
|
||||
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
||||
return [...episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
};
|
||||
/** Sort episodes in reverse chronological order (newest first) */
|
||||
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
||||
return [...episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||
const fetchEpisodes = async (
|
||||
feedUrl: string,
|
||||
limit: number,
|
||||
feedId?: string,
|
||||
): Promise<Episode[]> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feedUrl);
|
||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||
const fetchEpisodes = async (
|
||||
feedUrl: string,
|
||||
limit: number,
|
||||
feedId?: string,
|
||||
): Promise<Episode[]> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feedUrl);
|
||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
||||
|
||||
// Cache all parsed episodes for pagination
|
||||
if (feedId) {
|
||||
fullEpisodeCache.set(feedId, allEpisodes);
|
||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||
}
|
||||
// Cache all parsed episodes for pagination
|
||||
if (feedId) {
|
||||
fullEpisodeCache.set(feedId, allEpisodes);
|
||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||
}
|
||||
|
||||
return allEpisodes.slice(0, limit);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
return allEpisodes.slice(0, limit);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||
const addFeed = async (
|
||||
podcast: Podcast,
|
||||
sourceId: string,
|
||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||
) => {
|
||||
const feedId = crypto.randomUUID();
|
||||
const episodes = await fetchEpisodes(
|
||||
podcast.feedUrl,
|
||||
MAX_EPISODES_SUBSCRIBE,
|
||||
feedId,
|
||||
);
|
||||
const newFeed: Feed = {
|
||||
id: feedId,
|
||||
podcast,
|
||||
episodes,
|
||||
visibility,
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
};
|
||||
setFeeds((prev) => {
|
||||
const updated = [...prev, newFeed];
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
return newFeed;
|
||||
};
|
||||
/** Check if a feed with this URL already exists */
|
||||
const hasFeedByUrl = (feedUrl: string): boolean => {
|
||||
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
||||
};
|
||||
|
||||
/** Auto-download newest episodes for a feed */
|
||||
const autoDownloadEpisodes = (
|
||||
feedId: string,
|
||||
newEpisodes: Episode[],
|
||||
count: number,
|
||||
) => {
|
||||
try {
|
||||
const dlStore = useDownloadStore();
|
||||
// Sort by pubDate descending (newest first)
|
||||
const sorted = [...newEpisodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
// count = 0 means download all new episodes
|
||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||
for (const ep of toDownload) {
|
||||
const status = dlStore.getDownloadStatus(ep.id);
|
||||
if (
|
||||
status === DownloadStatus.NONE ||
|
||||
status === DownloadStatus.FAILED
|
||||
) {
|
||||
dlStore.startDownload(ep, feedId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Download store may not be available yet
|
||||
}
|
||||
};
|
||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||
const addFeed = async (
|
||||
podcast: Podcast,
|
||||
sourceId: string,
|
||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||
): Promise<Feed | null> => {
|
||||
// Guard: don't add a feed we already have (matched by feedUrl)
|
||||
if (hasFeedByUrl(podcast.feedUrl)) {
|
||||
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
||||
}
|
||||
|
||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||
const refreshFeed = async (feedId: string) => {
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
||||
const episodes = await fetchEpisodes(
|
||||
feed.podcast.feedUrl,
|
||||
MAX_EPISODES_REFRESH,
|
||||
feedId,
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
const feedId = crypto.randomUUID();
|
||||
const episodes = await fetchEpisodes(
|
||||
podcast.feedUrl,
|
||||
MAX_EPISODES_SUBSCRIBE,
|
||||
feedId,
|
||||
);
|
||||
const newFeed: Feed = {
|
||||
id: feedId,
|
||||
podcast,
|
||||
episodes,
|
||||
visibility,
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
};
|
||||
setFeeds((prev) => {
|
||||
const updated = [...prev, newFeed];
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
return newFeed;
|
||||
};
|
||||
|
||||
// Auto-download new episodes if enabled for this feed
|
||||
if (feed.autoDownload) {
|
||||
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
|
||||
if (newEpisodes.length > 0) {
|
||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
/** Auto-download newest episodes for a feed */
|
||||
const autoDownloadEpisodes = (
|
||||
feedId: string,
|
||||
newEpisodes: Episode[],
|
||||
count: number,
|
||||
) => {
|
||||
try {
|
||||
const dlStore = useDownloadStore();
|
||||
// Sort by pubDate descending (newest first)
|
||||
const sorted = [...newEpisodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
// count = 0 means download all new episodes
|
||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||
for (const ep of toDownload) {
|
||||
const status = dlStore.getDownloadStatus(ep.id);
|
||||
if (
|
||||
status === DownloadStatus.NONE ||
|
||||
status === DownloadStatus.FAILED
|
||||
) {
|
||||
dlStore.startDownload(ep, feedId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Download store may not be available yet
|
||||
}
|
||||
};
|
||||
|
||||
/** Refresh all feeds */
|
||||
const refreshAllFeeds = async () => {
|
||||
setIsLoadingFeeds(true);
|
||||
try {
|
||||
const currentFeeds = feeds();
|
||||
for (const feed of currentFeeds) {
|
||||
await refreshFeed(feed.id);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingFeeds(false);
|
||||
}
|
||||
};
|
||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||
const refreshFeed = async (feedId: string) => {
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
||||
const episodes = await fetchEpisodes(
|
||||
feed.podcast.feedUrl,
|
||||
MAX_EPISODES_REFRESH,
|
||||
feedId,
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const loadedFeeds = await loadFeedsFromFile();
|
||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
||||
await refreshAllFeeds();
|
||||
})();
|
||||
// Auto-download new episodes if enabled for this feed
|
||||
if (feed.autoDownload) {
|
||||
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
|
||||
if (newEpisodes.length > 0) {
|
||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Remove a feed */
|
||||
const removeFeed = (feedId: string) => {
|
||||
fullEpisodeCache.delete(feedId);
|
||||
episodeLoadCount.delete(feedId);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.filter((f) => f.id !== feedId);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
/** Refresh all feeds */
|
||||
const refreshAllFeeds = async () => {
|
||||
setIsLoadingFeeds(true);
|
||||
try {
|
||||
const currentFeeds = feeds();
|
||||
for (const feed of currentFeeds) {
|
||||
await refreshFeed(feed.id);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingFeeds(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Update a feed */
|
||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
(async () => {
|
||||
const loadedFeeds = await loadFeedsFromFile();
|
||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
||||
await refreshAllFeeds();
|
||||
})();
|
||||
|
||||
/** Toggle feed pinned status */
|
||||
const togglePinned = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
/** Remove a feed */
|
||||
const removeFeed = (feedId: string) => {
|
||||
fullEpisodeCache.delete(feedId);
|
||||
episodeLoadCount.delete(feedId);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.filter((f) => f.id !== feedId);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Add a source */
|
||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource];
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return newSource;
|
||||
};
|
||||
/** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
|
||||
const removeFeedByUrl = (feedUrl: string) => {
|
||||
const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
|
||||
if (feed) {
|
||||
fullEpisodeCache.delete(feed.id);
|
||||
episodeLoadCount.delete(feed.id);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Update a source */
|
||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, ...updates } : source,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
/** Update a feed */
|
||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Remove a source */
|
||||
const removeSource = (sourceId: string) => {
|
||||
// Don't remove default sources
|
||||
if (sourceId === "itunes" || sourceId === "rss") return false;
|
||||
/** Toggle feed pinned status */
|
||||
const togglePinned = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
setSources((prev) => {
|
||||
const updated = prev.filter((s) => s.id !== sourceId);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
/** Add a source */
|
||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource];
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return newSource;
|
||||
};
|
||||
|
||||
/** Toggle source enabled status */
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
/** Update a source */
|
||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, ...updates } : source,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Get feed by ID */
|
||||
const getFeed = (feedId: string): Feed | undefined => {
|
||||
return feeds().find((f) => f.id === feedId);
|
||||
};
|
||||
/** Remove a source */
|
||||
const removeSource = (sourceId: string) => {
|
||||
// Don't remove default sources
|
||||
if (sourceId === "itunes" || sourceId === "rss") return false;
|
||||
|
||||
/** Get selected feed */
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId();
|
||||
return id ? getFeed(id) : undefined;
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = prev.filter((s) => s.id !== sourceId);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||
const cached = fullEpisodeCache.get(feedId);
|
||||
if (!cached) return false;
|
||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||
return loaded < cached.length;
|
||||
};
|
||||
/** Toggle source enabled status */
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for a feed from the cache.
|
||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||
const loadMoreEpisodes = async (feedId: string) => {
|
||||
if (isLoadingMore()) return;
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
/** Get feed by ID */
|
||||
const getFeed = (feedId: string): Feed | undefined => {
|
||||
return feeds().find((f) => f.id === feedId);
|
||||
};
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
/** Get selected feed */
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId();
|
||||
return id ? getFeed(id) : undefined;
|
||||
};
|
||||
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
}
|
||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||
const cached = fullEpisodeCache.get(feedId);
|
||||
if (!cached) return false;
|
||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||
return loaded < cached.length;
|
||||
};
|
||||
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
/** Load the next chunk of episodes for a feed from the cache.
|
||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||
const loadMoreEpisodes = async (feedId: string) => {
|
||||
if (isLoadingMore()) return;
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
}
|
||||
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
|
||||
/** Set auto-download settings for a feed */
|
||||
const setAutoDownload = (
|
||||
feedId: string,
|
||||
enabled: boolean,
|
||||
count: number = 0,
|
||||
) => {
|
||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
||||
};
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
|
||||
return {
|
||||
// State
|
||||
feeds,
|
||||
sources,
|
||||
filter,
|
||||
selectedFeedId,
|
||||
isLoadingMore,
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
|
||||
// Computed
|
||||
getFilteredFeeds,
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
getSelectedFeed,
|
||||
hasMoreEpisodes,
|
||||
isLoadingFeeds,
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
addFeed,
|
||||
removeFeed,
|
||||
updateFeed,
|
||||
togglePinned,
|
||||
refreshFeed,
|
||||
refreshAllFeeds,
|
||||
loadMoreEpisodes,
|
||||
addSource,
|
||||
removeSource,
|
||||
toggleSource,
|
||||
updateSource,
|
||||
setAutoDownload,
|
||||
};
|
||||
/** Set auto-download settings for a feed */
|
||||
const setAutoDownload = (
|
||||
feedId: string,
|
||||
enabled: boolean,
|
||||
count: number = 0,
|
||||
) => {
|
||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
feeds,
|
||||
sources,
|
||||
filter,
|
||||
selectedFeedId,
|
||||
isLoadingMore,
|
||||
|
||||
// Computed
|
||||
getFilteredFeeds,
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
getSelectedFeed,
|
||||
hasMoreEpisodes,
|
||||
isLoadingFeeds,
|
||||
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
addFeed,
|
||||
hasFeedByUrl,
|
||||
removeFeed,
|
||||
removeFeedByUrl,
|
||||
updateFeed,
|
||||
togglePinned,
|
||||
refreshFeed,
|
||||
refreshAllFeeds,
|
||||
loadMoreEpisodes,
|
||||
addSource,
|
||||
removeSource,
|
||||
toggleSource,
|
||||
updateSource,
|
||||
setAutoDownload,
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton feed store */
|
||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||
|
||||
export function useFeedStore() {
|
||||
if (!feedStoreInstance) {
|
||||
feedStoreInstance = createFeedStore();
|
||||
}
|
||||
return feedStoreInstance;
|
||||
if (!feedStoreInstance) {
|
||||
feedStoreInstance = createFeedStore();
|
||||
}
|
||||
return feedStoreInstance;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user