mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
* fix: harden preload argv parsing for popup windows * fix: align youtube playback with shared overlay startup * fix: unwrap mpv youtube streams for anki media mining * docs: update docs for youtube subtitle and mining flow * refactor: unify cli and runtime wiring for startup and youtube flow * feat: update subtitle sidebar overlay behavior * chore: add shared log-file source for diagnostics * fix(ci): add changelog fragment for immersion changes * fix: address CodeRabbit review feedback * fix: persist canonical title from youtube metadata * style: format stats library tab * fix: address latest review feedback * style: format stats library files * test: stub launcher youtube deps in CI * test: isolate launcher youtube flow deps * test: stub launcher youtube deps in failing case * test: force x11 backend in launcher ci harness * test: address latest review feedback * fix(launcher): preserve user YouTube ytdl raw options * docs(backlog): update task tracking notes * fix(immersion): special-case youtube media paths in runtime and tracking * feat(stats): improve YouTube media metadata and picker key handling * fix(ci): format stats media library hook * fix: address latest CodeRabbit review items * docs: update youtube release notes and docs * feat: auto-load youtube subtitles before manual picker * fix: restore app-owned youtube subtitle flow * docs: update youtube playback docs and config copy * refactor: remove legacy youtube launcher mode plumbing * fix: refine youtube subtitle startup binding * docs: clarify youtube subtitle startup behavior * fix: address PR #31 latest review follow-ups * fix: address PR #31 follow-up review comments * test: harden youtube picker test harness * udpate backlog * fix: add timeout to youtube metadata probe * docs: refresh youtube and stats docs * update backlog * update backlog * chore: release v0.9.0
121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
|
import { formatDuration, formatNumber } from '../../lib/formatters';
|
|
import {
|
|
groupMediaLibraryItems,
|
|
summarizeMediaLibraryGroups,
|
|
} from '../../lib/media-library-grouping';
|
|
import { CoverImage } from './CoverImage';
|
|
import { MediaCard } from './MediaCard';
|
|
import { MediaDetailView } from './MediaDetailView';
|
|
|
|
interface LibraryTabProps {
|
|
onNavigateToSession: (sessionId: number) => void;
|
|
}
|
|
|
|
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|
const { media, loading, error } = useMediaLibrary();
|
|
const [search, setSearch] = useState('');
|
|
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!search.trim()) return media;
|
|
const q = search.toLowerCase();
|
|
return media.filter((m) => {
|
|
const haystacks = [
|
|
m.canonicalTitle,
|
|
m.videoTitle,
|
|
m.channelName,
|
|
m.uploaderId,
|
|
m.channelId,
|
|
].filter(Boolean);
|
|
return haystacks.some((value) => value!.toLowerCase().includes(q));
|
|
});
|
|
}, [media, search]);
|
|
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
|
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
|
|
|
|
if (selectedVideoId !== null) {
|
|
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
|
|
}
|
|
|
|
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
|
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Search titles..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
|
/>
|
|
<div className="text-xs text-ctp-overlay2 shrink-0">
|
|
{grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video
|
|
{summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)}
|
|
</div>
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{grouped.map((group) => (
|
|
<section
|
|
key={group.key}
|
|
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
|
>
|
|
<div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40">
|
|
<CoverImage
|
|
videoId={group.items[0]!.videoId}
|
|
title={group.title}
|
|
src={group.imageUrl}
|
|
className="w-16 h-16 rounded-2xl shrink-0"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
{group.channelUrl ? (
|
|
<a
|
|
href={group.channelUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors"
|
|
>
|
|
{group.title}
|
|
</a>
|
|
) : (
|
|
<h3 className="text-base font-semibold text-ctp-text truncate">
|
|
{group.title}
|
|
</h3>
|
|
)}
|
|
</div>
|
|
{group.subtitle ? (
|
|
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
|
|
) : null}
|
|
<div className="text-xs text-ctp-overlay2 mt-2">
|
|
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
|
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="p-4">
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{group.items.map((item) => (
|
|
<MediaCard
|
|
key={item.videoId}
|
|
item={item}
|
|
onClick={() => setSelectedVideoId(item.videoId)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|