diff --git a/web/src/App.tsx b/web/src/App.tsx
index 235a3b5..cbc8f69 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -3,6 +3,7 @@ import { TitleBar } from "./components/shell/TitleBar";
import { EditorSplit } from "./components/shell/EditorSplit";
import { HomeView } from "./components/home/HomeView";
import { SettingsView } from "./components/settings/SettingsView";
+import { LibraryView } from "./components/media/LibraryView";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { usePlaybackTicker } from "./hooks/usePlaybackTicker";
import { useAutosave } from "./hooks/useAutosave";
@@ -56,6 +57,7 @@ export default function App() {
if (view === "home") return ;
if (view === "settings") return ;
+ if (view === "library") return ;
return (
diff --git a/web/src/components/home/HomeView.tsx b/web/src/components/home/HomeView.tsx
index 6d2835b..3ef7477 100644
--- a/web/src/components/home/HomeView.tsx
+++ b/web/src/components/home/HomeView.tsx
@@ -8,7 +8,7 @@
*/
import { useState } from "react";
-import { Plus, FolderOpen, Settings as SettingsIcon, Film, Trash2 } from "lucide-react";
+import { Plus, FolderOpen, Settings as SettingsIcon, Film, Trash2, Library } from "lucide-react";
import { Icon } from "../ui/Icon";
import { useT } from "../../i18n";
import { useEditorUiStore } from "../../store/uiStore";
@@ -135,6 +135,8 @@ function Sidebar() {
/>
+ setView("library")} />
+
setView("settings")} />
diff --git a/web/src/components/media/LibraryView.tsx b/web/src/components/media/LibraryView.tsx
new file mode 100644
index 0000000..dfce84f
--- /dev/null
+++ b/web/src/components/media/LibraryView.tsx
@@ -0,0 +1,563 @@
+/**
+ * 全局素材库页(#56)。这是【跨项目】的全局收藏库,与项目内 `MediaPanel`(项目媒体)
+ * 是两套独立的东西:数据走全新的 `library_*` 命令(#55)+ `libraryStore`,不复用
+ * mediaStore。整页是独立全屏视图(uiStore.view === "library"),Home 与编辑器都能进。
+ *
+ * 布局:左侧分类树(全部 / 视频 / 音频 / 音效 / 图片 / 特效 + 自建分类),右侧工具条
+ * (搜索 + 排序)+ 网格。收藏在所有子库视图都可见(「全部」聚合),通过派生函数
+ * `selectEntries` 在前端过滤/排序,切分类不重拉。
+ *
+ * 每个条目卡片:缩略图(asset 协议解码原文件,缺省回退类型字形)、文件名、以及
+ * 「导入当前项目 / 改分类 / 取消收藏」操作。导入走 `importToProject`(库→项目 manifest
+ * 造新 asset,再 refreshMedia)。所有命令在非 Tauri 下安全降级。
+ */
+
+import { useEffect, useMemo, useState } from "react";
+import {
+ Home,
+ Search,
+ Star,
+ Film,
+ Music,
+ AudioWaveform,
+ Image as ImageIcon,
+ Sparkles,
+ Layers,
+ Tag,
+ Import,
+ Trash2,
+ type LucideIcon,
+} from "lucide-react";
+import { Icon } from "../ui/Icon";
+import { useT } from "../../i18n";
+import { useEditorUiStore } from "../../store/uiStore";
+import { assetUrl } from "../../lib/asset";
+import type { LibraryEntry } from "../../lib/libraryApi";
+import {
+ useLibraryStore,
+ startLibrarySync,
+ selectEntries,
+ deriveCustomCategories,
+ sourceName,
+ type SortKey,
+} from "../../store/libraryStore";
+
+/** 内置分类 → 图标 + i18n 键。顺序即树中显示顺序。 */
+const BUILTIN_CATEGORIES: ReadonlyArray<{ id: string; icon: LucideIcon; labelKey: string }> = [
+ { id: "all", icon: Star, labelKey: "library.cat.all" },
+ { id: "video", icon: Film, labelKey: "library.cat.video" },
+ { id: "audio", icon: Music, labelKey: "library.cat.audio" },
+ { id: "sound", icon: AudioWaveform, labelKey: "library.cat.sound" },
+ { id: "image", icon: ImageIcon, labelKey: "library.cat.image" },
+ { id: "effect", icon: Sparkles, labelKey: "library.cat.effect" },
+];
+
+const SORTS: ReadonlyArray<{ id: SortKey; labelKey: string }> = [
+ { id: "recent", labelKey: "library.sort.recent" },
+ { id: "oldest", labelKey: "library.sort.oldest" },
+ { id: "type", labelKey: "library.sort.type" },
+];
+
+/** 素材类型 → 卡片回退字形(无缩略图时)。 */
+function typeIcon(type: string): LucideIcon {
+ switch (type) {
+ case "video":
+ return Film;
+ case "audio":
+ return Music;
+ case "image":
+ return ImageIcon;
+ case "effect":
+ return Sparkles;
+ default:
+ return Layers;
+ }
+}
+
+export function LibraryView() {
+ const t = useT();
+ const setView = useEditorUiStore((s) => s.setView);
+ const entries = useLibraryStore((s) => s.entries);
+ const loading = useLibraryStore((s) => s.loading);
+ const error = useLibraryStore((s) => s.error);
+ const selectedCategory = useLibraryStore((s) => s.selectedCategory);
+ const search = useLibraryStore((s) => s.search);
+ const sort = useLibraryStore((s) => s.sort);
+ const setSearch = useLibraryStore((s) => s.setSearch);
+ const setSort = useLibraryStore((s) => s.setSort);
+
+ useEffect(() => {
+ void startLibrarySync();
+ }, []);
+
+ const visible = useMemo(
+ () => selectEntries(entries, selectedCategory, search, sort),
+ [entries, selectedCategory, search, sort],
+ );
+ const customCategories = useMemo(() => deriveCustomCategories(entries), [entries]);
+
+ return (
+
+
+
+
+ {/* 顶部条:返回主页 + 标题 + 搜索 + 排序 */}
+
+
+
+ {t("library.title")}
+
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ );
+}
+
+function CategoryTree({ custom }: { custom: ReadonlyArray }) {
+ const t = useT();
+ const selectedCategory = useLibraryStore((s) => s.selectedCategory);
+ const setSelectedCategory = useLibraryStore((s) => s.setSelectedCategory);
+
+ return (
+
+ );
+}
+
+function CategoryRow({
+ icon,
+ label,
+ active,
+ onClick,
+}: {
+ icon: LucideIcon;
+ label: string;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
+function SearchBox({
+ value,
+ onChange,
+ placeholder,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder: string;
+}) {
+ return (
+
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ style={{
+ width: 160,
+ border: "none",
+ outline: "none",
+ background: "transparent",
+ color: "var(--text-primary)",
+ fontSize: "var(--fs-sm)",
+ }}
+ />
+
+ );
+}
+
+function SortSelect({ value, onChange }: { value: SortKey; onChange: (s: SortKey) => void }) {
+ const t = useT();
+ return (
+
+ );
+}
+
+function Grid({
+ entries,
+ loading,
+ totalEmpty,
+}: {
+ entries: ReadonlyArray;
+ loading: boolean;
+ totalEmpty: boolean;
+}) {
+ const t = useT();
+
+ if (entries.length === 0) {
+ return (
+
+ {loading ? t("library.loading") : totalEmpty ? t("library.empty") : t("library.noMatch")}
+
+ );
+ }
+
+ return (
+
+
+ {entries.map((e) => (
+
+ ))}
+
+
+ );
+}
+
+function EntryCard({ entry }: { entry: LibraryEntry }) {
+ const t = useT();
+ const importToProject = useLibraryStore((s) => s.importToProject);
+ const unfavorite = useLibraryStore((s) => s.unfavorite);
+ const categorize = useLibraryStore((s) => s.categorize);
+ const [hovered, setHovered] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ const name = sourceName(entry.source) || entry.id;
+ // 缩略图:库条目 thumb 优先,否则按 source 让 WebView 解码原文件(asset 协议)。
+ const thumb = assetUrl(entry.thumb ?? entry.source);
+
+ const handleImport = async () => {
+ setBusy(true);
+ try {
+ await importToProject(entry.id);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleCategorize = () => {
+ const next = window.prompt(t("library.categorizePrompt"), entry.category ?? "");
+ if (next === null) return; // 取消
+ const trimmed = next.trim();
+ void categorize(entry.id, trimmed === "" ? null : trimmed);
+ };
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ title={name}
+ style={{ display: "flex", flexDirection: "column", gap: 4, position: "relative" }}
+ >
+
+ {thumb && entry.type === "image" ? (
+

+ ) : thumb && entry.type === "video" ? (
+
+ ) : (
+
+ )}
+
+ {/* hover 操作行:导入 / 改分类 / 取消收藏 */}
+ {hovered && (
+
+ void handleImport()}
+ disabled={busy}
+ />
+
+ void unfavorite(entry.id)}
+ />
+
+ )}
+
+
+
+ {name}
+
+
+ );
+}
+
+function CardAction({
+ icon,
+ title,
+ onClick,
+ danger,
+ disabled,
+}: {
+ icon: LucideIcon;
+ title: string;
+ onClick: () => void;
+ danger?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
diff --git a/web/src/components/shell/TitleBar.tsx b/web/src/components/shell/TitleBar.tsx
index 5654a4d..081e735 100644
--- a/web/src/components/shell/TitleBar.tsx
+++ b/web/src/components/shell/TitleBar.tsx
@@ -8,7 +8,7 @@
* menu, the in-app menu entry point for an environment without a native menu bar.
*/
-import { Upload, Home, Settings as SettingsIcon } from "lucide-react";
+import { Upload, Home, Settings as SettingsIcon, Library } from "lucide-react";
import { Icon } from "../ui/Icon";
import { ViewMenu } from "./ViewMenu";
import { useEditorUiStore } from "../../store/uiStore";
@@ -104,7 +104,23 @@ export function TitleBar() {
- {/* Trailing: Settings + Export. */}
+ {/* Trailing: Library + Settings + Export. */}
+