diff --git a/app/index.html b/app/index.html index c7a4be9f..2a155139 100644 --- a/app/index.html +++ b/app/index.html @@ -1,10 +1,26 @@ - + voicebox +
diff --git a/app/src/App.tsx b/app/src/App.tsx index cb57a010..b226eea5 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,6 +5,7 @@ import { DictateWindow } from '@/components/DictateWindow/DictateWindow'; import ShinyText from '@/components/ShinyText'; import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { useAutoUpdater } from '@/hooks/useAutoUpdater'; +import { useThemeSync } from '@/hooks/useThemeSync'; import { apiClient } from '@/lib/api/client'; import type { HealthResponse } from '@/lib/api/types'; import { useChordSync } from '@/lib/hooks/useChordSync'; @@ -71,6 +72,8 @@ const LOADING_MESSAGES = [ ]; function App() { + useThemeSync(); + // The dictate window runs in a separate Tauri webview that must skip // server bootstrap (the main window owns that lifecycle) and render only // the floating recording surface. Split into a sibling component so the diff --git a/app/src/components/CapturePill/CapturePill.tsx b/app/src/components/CapturePill/CapturePill.tsx index bc413d21..d8609415 100644 --- a/app/src/components/CapturePill/CapturePill.tsx +++ b/app/src/components/CapturePill/CapturePill.tsx @@ -135,8 +135,9 @@ export function CapturePill({ return (
{ const value = getComputedStyle(root).getPropertyValue(varName).trim(); - return value ? `hsl(${value} / ${alpha})` : ''; + if (!value) return ''; + const [h, s, l] = value.split(/\s+/); + if (!h || !s || !l) return ''; + return `hsla(${h}, ${s}, ${l}, ${alpha})`; }; const ws = WaveSurfer.create({ container, - waveColor: cssHsla('--foreground', 0.25), + waveColor: cssHsla('--muted-foreground', 1), progressColor: cssHsla('--accent', 1), cursorColor: 'transparent', barWidth: 2, diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index 89fdbc7c..7618490b 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -255,10 +255,10 @@ export function FloatingGenerateBox({ } /> + + } + /> diff --git a/app/src/components/ServerTab/ThemeSelect.tsx b/app/src/components/ServerTab/ThemeSelect.tsx new file mode 100644 index 00000000..b61bda5b --- /dev/null +++ b/app/src/components/ServerTab/ThemeSelect.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { type Theme, useUIStore } from '@/stores/uiStore'; + +export function ThemeSelect() { + const { t } = useTranslation(); + const theme = useUIStore((s) => s.theme); + const setTheme = useUIStore((s) => s.setTheme); + + return ( + + ); +} diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index c3f93683..906ebd99 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -47,15 +47,7 @@ export function Sidebar({ isMacOS }: SidebarProps) { > {/* Logo */}
- Voicebox + Voicebox
{/* Navigation Buttons */} diff --git a/app/src/components/StoriesTab/StoriesTab.tsx b/app/src/components/StoriesTab/StoriesTab.tsx index 7005092d..9eccff8f 100644 --- a/app/src/components/StoriesTab/StoriesTab.tsx +++ b/app/src/components/StoriesTab/StoriesTab.tsx @@ -7,7 +7,7 @@ export function StoriesTab() { const audioUrl = usePlayerStore((state) => state.audioUrl); return ( -
+
{/* Main content area */}
{/* Left Column - Story List */} @@ -16,7 +16,7 @@ export function StoriesTab() {
{/* Right Column - Story Content */} -
+
diff --git a/app/src/components/StoriesTab/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx index 10099a09..f70232db 100644 --- a/app/src/components/StoriesTab/StoryContent.tsx +++ b/app/src/components/StoriesTab/StoryContent.tsx @@ -72,8 +72,12 @@ export function StoryContent() { // Track editor is shown when story has items const hasBottomBar = story && story.items.length > 0; - // Calculate dynamic bottom padding: track editor + gap - const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0; + // Clear the floating generate box (always visible on this route) and the + // track editor bar when it's showing. + const FLOATING_BOX_CLEARANCE = 140; + const bottomPadding = hasBottomBar + ? trackEditorHeight + FLOATING_BOX_CLEARANCE + : FLOATING_BOX_CLEARANCE; // Drag and drop sensors const sensors = useSensors( @@ -265,9 +269,12 @@ export function StoryContent() { } return ( -
+
+ {/* Scroll Mask */} +
+ {/* Header */} -
+

{story.name}

{story.description && ( @@ -357,7 +364,7 @@ export function StoryContent() { {/* Content */}
0 ? `${bottomPadding}px` : undefined }} > {sortedItems.length === 0 ? ( diff --git a/app/src/components/StoriesTab/StoryList.tsx b/app/src/components/StoriesTab/StoryList.tsx index fab0ea3c..5698d458 100644 --- a/app/src/components/StoriesTab/StoryList.tsx +++ b/app/src/components/StoriesTab/StoryList.tsx @@ -1,5 +1,5 @@ import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertDialog, @@ -11,6 +11,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -62,6 +63,7 @@ export function StoryList() { const [deletingStoryId, setDeletingStoryId] = useState(null); const [newStoryName, setNewStoryName] = useState(''); const [newStoryDescription, setNewStoryDescription] = useState(''); + const [search, setSearch] = useState(''); const { toast } = useToast(); // Auto-select the first story when the list loads with no selection @@ -178,6 +180,19 @@ export function StoryList() { }); }; + const storyList = stories || []; + const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0; + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return storyList; + return storyList.filter((s) => { + const name = (s.name || '').toLowerCase(); + const description = (s.description || '').toLowerCase(); + return name.includes(q) || description.includes(q); + }); + }, [search, storyList]); + if (isLoading) { return (
@@ -186,77 +201,91 @@ export function StoryList() { ); } - const storyList = stories || []; - const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0; - return ( -
+
{/* Scroll Mask */} -
+
{/* Fixed Header */} -
-
-

{t('stories.title')}

+
+
+

{t('stories.title')}

+
+ setSearch(e.target.value)} + className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
{/* Scrollable Story List */}
{storyList.length === 0 ? ( -
+

{t('stories.empty.title')}

{t('stories.empty.hint')}

+ ) : filtered.length === 0 ? ( +
+

{t('stories.empty.noMatches', { query: search })}

+
) : ( -
- {storyList.map((story) => ( -
setSelectedStoryId(story.id)} - onKeyDown={(e) => { - if (e.target !== e.currentTarget) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setSelectedStoryId(story.id); - } - }} - > -
-
-

{story.name}

-
- {t('stories.row.itemCount', { count: story.item_count })} - · - {formatDate(story.updated_at)} +
+ {filtered.map((story) => { + const isActive = selectedStoryId === story.id; + return ( +
+
-
- ))} + ); + })}
)}
diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 96cbcef1..d0afe8e3 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -125,6 +125,8 @@ interface StoryTrackEditorProps { const TRACK_HEIGHT = 48; const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px +const SCRUB_BAR_HEIGHT = 16; +const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px const MIN_PIXELS_PER_SECOND = 10; const MAX_PIXELS_PER_SECOND = 200; const DEFAULT_PIXELS_PER_SECOND = 50; @@ -282,6 +284,44 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { return () => observer.disconnect(); }, []); + // Horizontal scrollbar state + const [timelineScrollLeft, setTimelineScrollLeft] = useState(0); + const [scrollbarTrackWidth, setScrollbarTrackWidth] = useState(0); + const scrollbarTrackRef = useRef(null); + const scrollbarDragRef = useRef<{ + mode: 'pan' | 'left' | 'right'; + startX: number; + startScrollLeft: number; + startPixelsPerSecond: number; + } | null>(null); + // Anchor the visible left/right edge time during a zoom drag so the edge + // the user isn't dragging stays pinned in place across pixelsPerSecond changes. + const zoomAnchorRef = useRef<{ type: 'left' | 'right'; timeMs: number } | null>(null); + + // Mirror the timeline's scrollLeft into state so the scrollbar thumb tracks it + useEffect(() => { + const el = tracksRef.current; + if (!el) return; + const onScroll = () => setTimelineScrollLeft(el.scrollLeft); + el.addEventListener('scroll', onScroll); + setTimelineScrollLeft(el.scrollLeft); + return () => el.removeEventListener('scroll', onScroll); + }, []); + + // Track scrollbar track width for thumb sizing + useEffect(() => { + const el = scrollbarTrackRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + setScrollbarTrackWidth(entry.contentRect.width); + } + }); + ro.observe(el); + setScrollbarTrackWidth(el.clientWidth); + return () => ro.disconnect(); + }, []); + // Calculate effective duration (accounting for trims) const getEffectiveDuration = (item: StoryItemDetail) => { return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0); @@ -374,7 +414,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const handleTimelineClick = (e: React.MouseEvent) => { if (!tracksRef.current || draggingItem || trimmingItem) return; const rect = tracksRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + tracksRef.current.scrollLeft; + const x = e.clientX - rect.left + tracksRef.current.scrollLeft - LABEL_COL_WIDTH; const timeMs = Math.max(0, pixelsToMs(x)); seek(timeMs); // Deselect clip when clicking on timeline @@ -654,7 +694,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { y: e.clientY - rect.top, }); setDragPosition({ - x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft, + // Subtract label column width because clips live in a sub-container offset + // by LABEL_COL_WIDTH, so dragPosition.x is stored in timeline-local coords. + x: + rect.left - + tracksRef.current.getBoundingClientRect().left + + tracksRef.current.scrollLeft - + LABEL_COL_WIDTH, // Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT, }); @@ -666,7 +712,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { if (!draggingItem || !tracksRef.current) return; const rect = tracksRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x; + const x = + e.clientX - + rect.left + + tracksRef.current.scrollLeft - + dragOffset.x - + LABEL_COL_WIDTH; // Subtract ruler height since clips are positioned relative to tracks area const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT; @@ -762,7 +813,109 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { // Calculate tracks area height const tracksAreaHeight = tracks.length * TRACK_HEIGHT; - const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height + const timelineContainerHeight = editorHeight - 40 - SCRUB_BAR_HEIGHT; + + // Scrollbar thumb geometry + const maxTimelineScroll = Math.max(0, timelineWidth - containerWidth); + const visibleRatio = timelineWidth > 0 ? Math.min(1, containerWidth / timelineWidth) : 1; + const thumbWidth = Math.max(24, visibleRatio * scrollbarTrackWidth); + const thumbRange = Math.max(0, scrollbarTrackWidth - thumbWidth); + const thumbLeft = + maxTimelineScroll > 0 && thumbRange > 0 + ? (timelineScrollLeft / maxTimelineScroll) * thumbRange + : 0; + const canScrollHorizontally = maxTimelineScroll > 0; + + const handleScrollbarMouseDown = useCallback( + (mode: 'pan' | 'left' | 'right') => (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + scrollbarDragRef.current = { + mode, + startX: e.clientX, + startScrollLeft: timelineScrollLeft, + startPixelsPerSecond: pixelsPerSecond, + }; + }, + [timelineScrollLeft, pixelsPerSecond], + ); + + // After a zoom drag updates pixelsPerSecond, snap scrollLeft so the anchored + // edge (left or right of the visible window) stays at the same time. + useEffect(() => { + const anchor = zoomAnchorRef.current; + if (!anchor || !tracksRef.current) return; + const timePx = (anchor.timeMs / 1000) * pixelsPerSecond; + tracksRef.current.scrollLeft = + anchor.type === 'left' ? Math.max(0, timePx) : Math.max(0, timePx - containerWidth); + }, [pixelsPerSecond, containerWidth]); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + const drag = scrollbarDragRef.current; + if (!drag || !tracksRef.current) return; + const deltaX = e.clientX - drag.startX; + + if (drag.mode === 'pan') { + if (thumbRange <= 0) return; + const deltaScroll = (deltaX / thumbRange) * maxTimelineScroll; + tracksRef.current.scrollLeft = Math.max( + 0, + Math.min(maxTimelineScroll, drag.startScrollLeft + deltaScroll), + ); + return; + } + + if (scrollbarTrackWidth <= 0 || containerWidth <= 0) return; + + // Recompute the thumb width that corresponded to the drag start, then + // apply the mouse delta to the dragged edge. + const startTimelinePx = + (totalDurationMs / 1000) * drag.startPixelsPerSecond + 200; + const startThumbWidth = Math.max( + 30, + Math.min(scrollbarTrackWidth, (containerWidth / startTimelinePx) * scrollbarTrackWidth), + ); + const newThumbWidth = Math.max( + 30, + Math.min( + scrollbarTrackWidth, + drag.mode === 'right' ? startThumbWidth + deltaX : startThumbWidth - deltaX, + ), + ); + + const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth; + const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000); + const newPps = Math.max( + MIN_PIXELS_PER_SECOND, + Math.min(MAX_PIXELS_PER_SECOND, rawPps), + ); + + zoomAnchorRef.current = + drag.mode === 'right' + ? { + type: 'left', + timeMs: (drag.startScrollLeft / drag.startPixelsPerSecond) * 1000, + } + : { + type: 'right', + timeMs: + ((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000, + }; + + setPixelsPerSecond(newPps); + }; + const onMouseUp = () => { + scrollbarDragRef.current = null; + zoomAnchorRef.current = null; + }; + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs]); if (items.length === 0) { return null; @@ -916,44 +1069,25 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
- {/* Timeline container with track labels sidebar */} -
- {/* Track labels sidebar - fixed width */} -
- {/* Spacer for time ruler */} -
- {/* Track labels */} -
- {tracks.map((trackNumber, index) => ( -
- - {trackNumber} - -
- ))} -
-
- - {/* Scrollable timeline area */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */} + {/* Timeline scroll container */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */} +
+ {/* Ruler row: corner spacer + time ruler, sticky to top */}
- {/* Time ruler - clickable to seek */} +
+
- {/* Tracks area */} -
- {/* Track backgrounds - pointer-events-none to allow clicks to pass through */} - {tracks.map((trackNumber, index) => ( + {/* Tracks area (rows with sticky labels + clips sub-container) */} +
+ {/* Per-track rows: label and background as flex siblings guarantee alignment */} + {tracks.map((trackNumber, index) => ( +
+
+
+ + {trackNumber} + +
- ))} +
+ ))} + {/* Clip/playhead/seek layer offset past the label column */} +
{/* Click area for seeking - z-index lower than clips */}
+ + {/* Horizontal timeline scrollbar + zoom handles */} +
+
+
+
+ {/* Left zoom handle */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */} +
+ {/* Pan area */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven drag area */} +
+ {/* Right zoom handle */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */} +
+
+
+
); diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index a7b7f8ef..e9042a57 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -91,7 +91,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { className={cn( 'cursor-pointer transition-all flex flex-col h-[162px]', disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md', - isSelected && !disabled && 'ring-2 ring-accent shadow-md', + isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md', )} onClick={handleSelect} tabIndex={0} diff --git a/app/src/components/ui/badge.tsx b/app/src/components/ui/badge.tsx index fb60e90d..20c6eec3 100644 --- a/app/src/components/ui/badge.tsx +++ b/app/src/components/ui/badge.tsx @@ -9,7 +9,7 @@ const badgeVariants = cva( variant: { default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', secondary: - 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + 'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80', destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', outline: 'text-foreground', diff --git a/app/src/hooks/useThemeSync.ts b/app/src/hooks/useThemeSync.ts new file mode 100644 index 00000000..945792a1 --- /dev/null +++ b/app/src/hooks/useThemeSync.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useUIStore } from '@/stores/uiStore'; + +export function useThemeSync() { + const theme = useUIStore((s) => s.theme); + + useEffect(() => { + if (theme !== 'system') { + document.documentElement.classList.toggle('dark', theme === 'dark'); + return; + } + + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const apply = () => { + document.documentElement.classList.toggle('dark', mq.matches); + }; + + apply(); + mq.addEventListener('change', apply); + return () => mq.removeEventListener('change', apply); + }, [theme]); +} diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 9661fcb8..0a7095fb 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -551,9 +551,11 @@ "title": "Stories", "newStory": "New Story", "loading": "Loading stories…", + "searchPlaceholder": "Search stories…", "empty": { "title": "No stories yet", - "hint": "Create your first story to get started" + "hint": "Create your first story to get started", + "noMatches": "No stories match \"{{query}}\"" }, "row": { "itemCount_one": "{{count}} item", @@ -730,6 +732,15 @@ "label": "Language", "description": "Choose the display language for Voicebox." }, + "theme": { + "label": "Theme", + "description": "Match your system, or pick a fixed light or dark appearance.", + "options": { + "system": "System", + "light": "Light", + "dark": "Dark" + } + }, "general": { "docs": { "title": "Read the Docs" }, "discord": { "title": "Join the Discord", "subtitle": "Get help & share voices" }, diff --git a/app/src/i18n/locales/ja/translation.json b/app/src/i18n/locales/ja/translation.json index ca302c0d..aef6e030 100644 --- a/app/src/i18n/locales/ja/translation.json +++ b/app/src/i18n/locales/ja/translation.json @@ -551,9 +551,11 @@ "title": "ストーリー", "newStory": "新しいストーリー", "loading": "ストーリーを読み込み中…", + "searchPlaceholder": "ストーリーを検索…", "empty": { "title": "ストーリーがまだありません", - "hint": "最初のストーリーを作成して始めましょう" + "hint": "最初のストーリーを作成して始めましょう", + "noMatches": "「{{query}}」に一致するストーリーはありません" }, "row": { "itemCount_one": "{{count}} 項目", @@ -730,6 +732,15 @@ "label": "言語", "description": "Voicebox の表示言語を選択します。" }, + "theme": { + "label": "テーマ", + "description": "システム設定に合わせるか、ライト / ダークを固定します。", + "options": { + "system": "システム", + "light": "ライト", + "dark": "ダーク" + } + }, "general": { "docs": { "title": "ドキュメントを読む" }, "discord": { "title": "Discord に参加", "subtitle": "ヘルプやボイスの共有" }, diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json index a479e469..e8ff71b5 100644 --- a/app/src/i18n/locales/zh-CN/translation.json +++ b/app/src/i18n/locales/zh-CN/translation.json @@ -551,9 +551,11 @@ "title": "故事", "newStory": "新建故事", "loading": "加载故事中…", + "searchPlaceholder": "搜索故事…", "empty": { "title": "暂无故事", - "hint": "创建您的第一个故事以开始" + "hint": "创建您的第一个故事以开始", + "noMatches": "没有故事匹配 “{{query}}”" }, "row": { "itemCount_one": "{{count}} 项", @@ -730,6 +732,15 @@ "label": "语言", "description": "选择 Voicebox 的显示语言。" }, + "theme": { + "label": "主题", + "description": "跟随系统外观,或固定为浅色 / 深色模式。", + "options": { + "system": "跟随系统", + "light": "浅色", + "dark": "深色" + } + }, "general": { "docs": { "title": "阅读文档" }, "discord": { "title": "加入 Discord", "subtitle": "获取帮助 & 分享声音" }, diff --git a/app/src/i18n/locales/zh-TW/translation.json b/app/src/i18n/locales/zh-TW/translation.json index eed6c07a..f96b75df 100644 --- a/app/src/i18n/locales/zh-TW/translation.json +++ b/app/src/i18n/locales/zh-TW/translation.json @@ -551,9 +551,11 @@ "title": "故事", "newStory": "新增故事", "loading": "載入故事中…", + "searchPlaceholder": "搜尋故事…", "empty": { "title": "尚無故事", - "hint": "建立您的第一個故事以開始" + "hint": "建立您的第一個故事以開始", + "noMatches": "沒有故事符合「{{query}}」" }, "row": { "itemCount_one": "{{count}} 項", @@ -730,6 +732,15 @@ "label": "語言", "description": "選擇 Voicebox 的顯示語言。" }, + "theme": { + "label": "佈景主題", + "description": "跟隨系統外觀,或固定為淺色 / 深色模式。", + "options": { + "system": "跟隨系統", + "light": "淺色", + "dark": "深色" + } + }, "general": { "docs": { "title": "閱讀文件" }, "discord": { "title": "加入 Discord", "subtitle": "取得協助與分享聲音" }, diff --git a/app/src/index.css b/app/src/index.css index 65c11d84..03b1b294 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -44,24 +44,24 @@ :root { --background: 0 0% 95%; - --foreground: 222.2 84% 4.9%; + --foreground: 0 0% 5%; --card: 0 0% 97%; - --card-foreground: 222.2 84% 4.9%; + --card-foreground: 0 0% 5%; --popover: 0 0% 97%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 92%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 90%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 43 50% 50%; - --accent-foreground: 222.2 47.4% 11.2%; + --popover-foreground: 0 0% 5%; + --primary: 43 55% 58%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 92%; + --secondary-foreground: 0 0% 11%; + --muted: 0 0% 90%; + --muted-foreground: 0 0% 47%; + --accent: 43 55% 58%; + --accent-foreground: 0 0% 100%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 85%; - --input: 214.3 31.8% 88%; - --ring: 222.2 84% 4.9%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 85%; + --input: 0 0% 88%; + --ring: 0 0% 5%; --sidebar: 0 0% 92%; --radius: 0.5rem; --chart-1: 12 76% 61%; @@ -157,6 +157,11 @@ opacity: 0; } +.dark .sidebar-logo { + filter: drop-shadow(0 0 6px hsl(var(--accent) / 0.5)) + drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2)); +} + /* react-loaders */ .line-scale-pulse-out-rapid > div, .line-scale > div { diff --git a/app/src/stores/uiStore.ts b/app/src/stores/uiStore.ts index f90fc13d..dfaf6d2f 100644 --- a/app/src/stores/uiStore.ts +++ b/app/src/stores/uiStore.ts @@ -1,6 +1,19 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +export type Theme = 'light' | 'dark' | 'system'; + +function resolveTheme(theme: Theme): 'light' | 'dark' { + if (theme !== 'system') return theme; + if (typeof window === 'undefined') return 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function applyTheme(theme: Theme) { + if (typeof document === 'undefined') return; + document.documentElement.classList.toggle('dark', resolveTheme(theme) === 'dark'); +} + // Draft state for the create voice profile form export interface ProfileFormDraft { name: string; @@ -46,8 +59,8 @@ interface UIStore { setProfileFormDraft: (draft: ProfileFormDraft | null) => void; // Theme - theme: 'light' | 'dark'; - setTheme: (theme: 'light' | 'dark') => void; + theme: Theme; + setTheme: (theme: Theme) => void; } export const useUIStore = create()( @@ -76,15 +89,21 @@ export const useUIStore = create()( profileFormDraft: null, setProfileFormDraft: (draft) => set({ profileFormDraft: draft }), - theme: 'light', + theme: 'system', setTheme: (theme) => { set({ theme }); - document.documentElement.classList.toggle('dark', theme === 'dark'); + applyTheme(theme); }, }), { name: 'voicebox-ui', - partialize: (state) => ({ selectedProfileId: state.selectedProfileId }), + partialize: (state) => ({ + selectedProfileId: state.selectedProfileId, + theme: state.theme, + }), + onRehydrateStorage: () => (state) => { + if (state) applyTheme(state.theme); + }, }, ), ); diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec index 3e5fac49..7ef6c63d 100644 --- a/backend/voicebox-server.spec +++ b/backend/voicebox-server.spec @@ -5,7 +5,7 @@ from PyInstaller.utils.hooks import copy_metadata datas = [] binaries = [] -hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] +hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.mcp_server', 'backend.mcp_server.server', 'backend.mcp_server.tools', 'backend.mcp_server.context', 'backend.mcp_server.resolve', 'backend.mcp_server.events', 'sse_starlette', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] datas += copy_metadata('qwen-tts') datas += copy_metadata('requests') datas += copy_metadata('transformers') @@ -48,6 +48,10 @@ tmp_ret = collect_all('en_core_web_sm') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('unidic_lite') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('fastmcp') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('mcp') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('mlx') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('mlx_audio') diff --git a/docs/plans/MACOS_NOTARIZATION.md b/docs/plans/MACOS_NOTARIZATION.md new file mode 100644 index 00000000..d960c381 --- /dev/null +++ b/docs/plans/MACOS_NOTARIZATION.md @@ -0,0 +1,102 @@ +# macOS Notarization & Gatekeeper + +**Status:** Diagnosis — Homebrew Cask CI rejects v0.4.5 on macOS 15 (Sequoia); fix pending +**Touches:** `.github/workflows/release.yml`, Tauri bundler config, sidecar signing +**Last reviewed:** 2026-04-24 + +## Context + +Homebrew Cask PR [#260314](https://github.com/Homebrew/homebrew-cask/pull/260314) adds `brew install --cask voicebox`. CI is green on macOS 14 and macOS 26 (arm + intel) but fails on macOS 15 (arm + intel). The 0.4.3 release added DMG-level stapling to address this, and it didn't move CI — 0.4.5 still fails. A maintainer reproduced the failure in a fresh Sequoia VM. + +This document is the working diagnosis plus the ordered fix plan. + +## What the failing check actually does + +The failing step is `brew audit --cask --online --signing --new voicebox`, not `brew install`. `brew install` succeeds end-to-end in CI (the log shows `Uninstalling Cask voicebox` after the install phase). The `--signing` audit: + +1. Downloads the cask's `url` +2. Mounts the DMG +3. Runs `spctl --assess -t open --context context:primary-signature` against the `.app` inside + +That policy tests the first-launch Gatekeeper path on the extracted bundle. It reads the `.app`'s own code signature and notarization ticket — the DMG wrapper is not involved. The staple added in 0.4.3 covers the DMG, so it has no effect on this audit. + +## Why Sequoia and not Sonoma + +`spctl -t open` on macOS 15 enforces checks that 14 tolerated: + +- Secure timestamp required on hardened-runtime signatures. Untimestamped signatures pass on 14, fail on 15. +- Deep verification of nested Mach-Os. If any embedded `.dylib` or helper binary carries an ad-hoc signature (or a signature with a different Team ID), 15 rejects the whole bundle; 14 often accepted it. +- Hardened runtime must be set on every nested executable, not just the top-level app binary. Entitlements declared on the outer app do not propagate. + +Local dev machines pass `spctl` because the first-party developer context and cached notarization tickets mask these failures. A fresh Sequoia VM with no prior trust state does not. + +## Where the gap is likely to be + +Voicebox ships PyInstaller sidecars declared in `tauri.conf.json` under `externalBin`: + +- **0.4.x:** `voicebox-server` only (single `--onefile` Mach-O on macOS) +- **0.5.0+:** `voicebox-server` and `voicebox-mcp` (`voicebox-mcp` is new in 0.5.0) + +Tauri's bundler signs each `externalBin` with the configured identity but does not apply `--options=runtime` or `--timestamp` automatically, and does not merge the outer app's entitlements into the sidecar signature. The outer `Voicebox` binary is correctly signed with hardened runtime + `disable-library-validation`; the sidecars likely are not. + +Order of likelihood: + +1. Sidecar `voicebox-server` lacks hardened runtime or a secure timestamp in its signature. +2. The sidecar inherits the identity but was signed before tauri-action's final notarization pass, so the notarization ticket doesn't actually cover it. +3. Something inside the sidecar's PyInstaller archive unpacks to a `.dylib` at runtime that Gatekeeper inspects during assessment. + +The 0.5.0 fix must cover both sidecars. + +## Diagnostic commands + +Run against a freshly downloaded release DMG (not a dev build, and from a machine that has never opened the app before): + +``` +hdiutil attach Voicebox_0.4.5_aarch64.dmg +xcrun stapler validate "/Volumes/Voicebox 0.4.5/Voicebox.app" +spctl -a -vvv -t open --context context:primary-signature "/Volumes/Voicebox 0.4.5/Voicebox.app" +codesign --verify --deep --strict --verbose=2 "/Volumes/Voicebox 0.4.5/Voicebox.app" +codesign -dv --verbose=4 "/Volumes/Voicebox 0.4.5/Voicebox.app/Contents/MacOS/voicebox-server" +``` + +The last command is the tell — look for `flags=0x10000(runtime)` and a `Timestamp=` line. If either is missing, the sidecar is the failure. + +`spctl -t install` (what 0.4.3 verified with) is a different policy and can pass while `-t open` fails — any future verification should use `-t open --context context:primary-signature` to match what Homebrew's audit runs. + +## Phases + +### Phase 1 — Confirm the failure mode + +Pull the 0.4.5 DMG on a fresh Sequoia environment or a VM snapshot with no trust state. Run the diagnostic block above. Record the exact failing command and its CSSMERR / rejection reason. This disambiguates between the three hypotheses before we change the workflow. + +### Phase 2 — Sign sidecars explicitly in the release workflow + +Between tauri-action's build step and the DMG-notarization step already in `release.yml`, add a step that re-signs every `externalBin` present under `Voicebox.app/Contents/MacOS/` with: + +- `--options=runtime` (hardened runtime) +- `--timestamp` (secure timestamp) +- `--entitlements` pointing at `Entitlements.plist` or a sidecar-specific subset +- The same `APPLE_SIGNING_IDENTITY` the outer app uses + +Re-sign the outer `.app` afterward so its seal covers the updated nested signatures. + +Covers `voicebox-server` on 0.4.x and both sidecars from 0.5.0 forward. + +### Phase 3 — Re-notarize and staple the `.app` + +After sidecars are re-signed the outer bundle's notarization ticket is stale. Submit the `.app` (zipped) to `notarytool`, wait, then `xcrun stapler staple Voicebox.app`. This puts the ticket directly on the `.app` so the `spctl -t open` audit passes without any online ticket lookup. + +Then rebuild the DMG from the stapled `.app` and keep the existing DMG-level notarize/staple step — it still helps Finder drag-install. + +### Phase 4 — CI verification gate in the release workflow + +Before upload, run the same four diagnostic commands against the built artifact inside the workflow. If any fail, fail the release job rather than shipping a DMG that Homebrew (and Sequoia Finder users) will reject. This is the check that would have caught the 0.4.3 and 0.4.5 attempts before they cost PR review cycles. + +### Phase 5 — Re-request Homebrew CI + +Once a tagged release passes Phase 4 locally, push a cask update to #260314. Expect `test voicebox (macos-15, arm)` and `test voicebox (macos-15-intel, intel)` to go green. + +## Open questions + +- Does tauri-action v0.6 pass `APPLE_API_KEY_PATH` to the bundler's notarize path, or does it rely on the `~/.appstoreconnect/private_keys/AuthKey_*.p8` auto-discovery the staple step already sets up? If the former isn't working, tauri may be signing but never notarizing the `.app`, which would make the ticket absent entirely rather than stale. Worth a `grep -i notariz` on a full release job log. +- If Phase 2 resolves the macOS 15 failure, revisit whether the 0.4.3 DMG staple step is still needed. It's cheap to keep and helps the Finder-open case, so default to leaving it. diff --git a/landing/src/components/Personalities.tsx b/landing/src/components/Personalities.tsx index 07e2ec3a..296fc8bd 100644 --- a/landing/src/components/Personalities.tsx +++ b/landing/src/components/Personalities.tsx @@ -1,36 +1,27 @@ 'use client'; import { AnimatePresence, motion } from 'framer-motion'; -import { ArrowRight, MessageSquareReply, PenLine, Sparkles } from 'lucide-react'; +import { ArrowRight, Dices, Wand2 } from 'lucide-react'; import { useEffect, useState } from 'react'; // ─── Modes ────────────────────────────────────────────────────────────────── type Mode = { - id: 'compose' | 'rewrite' | 'respond'; + id: 'compose' | 'rewrite'; label: string; - icon: typeof Sparkles; - inputLabel: string; + icon: typeof Dices; outputLabel: string; - input: string; output: string; -}; +} & ( + | { inputLabel: string; input: string } + | { inputLabel?: undefined; input?: undefined } +); const MODES: Mode[] = [ - { - id: 'compose', - label: 'Compose', - icon: Sparkles, - inputLabel: 'Prompt', - outputLabel: "Marlowe, in character", - input: 'celebrate the deploy going green', - output: - "She came through clean. Not a single test casting a shadow. In this town, that's usually when you start worrying.", - }, { id: 'rewrite', label: 'Rewrite', - icon: PenLine, + icon: Wand2, inputLabel: 'Your text', outputLabel: "Marlowe, in character", input: 'the build is done and we shipped to production', @@ -38,14 +29,12 @@ const MODES: Mode[] = [ "Build's wrapped, ship's left the dock. Another stack of code makes its way into prod, another row of green checks lining the wall.", }, { - id: 'respond', - label: 'Respond', - icon: MessageSquareReply, - inputLabel: 'Question', + id: 'compose', + label: 'Compose', + icon: Dices, outputLabel: "Marlowe, in character", - input: 'should I refactor this before merging or do it after?', output: - "Listen, kid. You can polish the brass on the door, or you can open it. Open the door — refactor in daylight.", + "She came through clean. Not a single test casting a shadow. In this town, that's usually when you start worrying.", }, ]; @@ -116,14 +105,26 @@ function ModeDemo({ mode, cycleKey }: { mode: Mode; cycleKey: number }) { className="flex flex-col gap-4 flex-1" > {/* Input */} -
-
- {mode.inputLabel} + {mode.input ? ( +
+
+ {mode.inputLabel} +
+
+ {mode.input} +
-
- {mode.input} + ) : ( +
+
+ No input +
+
+ + Click Compose — the character improvises a fresh line. +
-
+ )} {/* Arrow */}
@@ -151,22 +152,16 @@ function ModeDemo({ mode, cycleKey }: { mode: Mode; cycleKey: number }) { const BULLETS = [ { - icon: Sparkles, - title: 'Compose', - description: - 'Generate a fresh utterance in the character’s voice from a short prompt. Useful for game dialogue, narration cues, or character barks.', - }, - { - icon: PenLine, + icon: Wand2, title: 'Rewrite', description: 'Restate your text in their voice while preserving every idea. Same content, their delivery — for scripts, dubs, and consistent character voice across long-form work.', }, { - icon: MessageSquareReply, - title: 'Respond', + icon: Dices, + title: 'Compose', description: - 'Treat your text as a prompt and produce the character’s reply. The persona half of the dictation → speak loop.', + 'No input needed — hit the button and the character improvises a fresh line of their own. Roll again for another take. Useful for game dialogue, narration cues, or character barks.', }, ]; @@ -197,9 +192,8 @@ export function Personalities() {

Give any voice profile a free-form personality. Then{' '} - Compose,{' '} - Rewrite, or{' '} - Respond — your cloned voice, in full character. + Rewrite your text in their voice, or let them{' '} + Compose a fresh line of their own — your cloned voice, in full character.

@@ -210,7 +204,7 @@ export function Personalities() {
{/* Bullets */} -
+
{BULLETS.map((bullet) => { const Icon = bullet.icon; return ( diff --git a/tauri/index.html b/tauri/index.html index a1bf100a..b944680f 100644 --- a/tauri/index.html +++ b/tauri/index.html @@ -1,9 +1,25 @@ - + voicebox +