From 80f6b544b958c72aeac443ce8d4b1d2211e88862 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 7 May 2026 15:18:07 -0300 Subject: [PATCH 01/10] fix: add estimated time remaining calculation for batch processing --- .../transcription/hooks/useBatchQueue.ts | 53 +++++++++++++++++-- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/src/renderer/features/transcription/hooks/useBatchQueue.ts b/src/renderer/features/transcription/hooks/useBatchQueue.ts index 753c05d..008b063 100644 --- a/src/renderer/features/transcription/hooks/useBatchQueue.ts +++ b/src/renderer/features/transcription/hooks/useBatchQueue.ts @@ -248,6 +248,8 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const activeRunItemIdsRef = useRef>(new Set()); const currentItemStartTimeRef = useRef(null); const remainingPendingCountRef = useRef(0); + const lastProgressPercentRef = useRef(0); + const etaIntervalRef = useRef | null>(null); useEffect(() => { const restoredCount = initialQueueLengthRef.current; @@ -292,9 +294,49 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur progressUnsubscribeRef.current(); progressUnsubscribeRef.current = null; } + if (etaIntervalRef.current !== null) { + clearInterval(etaIntervalRef.current); + etaIntervalRef.current = null; + } }; }, []); + useEffect(() => { + if (!isProcessing) { + if (etaIntervalRef.current !== null) { + clearInterval(etaIntervalRef.current); + etaIntervalRef.current = null; + } + return; + } + + etaIntervalRef.current = setInterval(() => { + const startTimeMs = currentItemStartTimeRef.current; + const progressPercent = lastProgressPercentRef.current; + + if (isCancelledRef.current || startTimeMs === null || progressPercent <= 0) { + return; + } + + const elapsedMs = Date.now() - startTimeMs; + if (elapsedMs <= 0) return; + + const projectedItemDurationMs = elapsedMs / (progressPercent / 100); + const remainingCurrentMs = Math.max(0, projectedItemDurationMs - elapsedMs); + const remainingMs = + remainingCurrentMs + projectedItemDurationMs * remainingPendingCountRef.current; + + setEstimatedTimeRemainingSec(Math.max(1, Math.round(remainingMs / 1000))); + }, 1000); + + return () => { + if (etaIntervalRef.current !== null) { + clearInterval(etaIntervalRef.current); + etaIntervalRef.current = null; + } + }; + }, [isProcessing]); + const addFiles = useCallback((files: SelectedFile[]) => { const existingKeys = new Set(queueRef.current.map((item) => getFileIdentityKey(item.file))); const duplicateFiles: SelectedFile[] = []; @@ -385,6 +427,8 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur progressUnsubscribeRef.current = null; } + lastProgressPercentRef.current = 0; + progressUnsubscribeRef.current = onTranscriptionProgress((progress) => { setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, progress } : q))); @@ -401,18 +445,17 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur return; } + lastProgressPercentRef.current = progressPercent; + const elapsedMs = Date.now() - startTimeMs; - if (elapsedMs <= 0) { - return; - } + if (elapsedMs <= 0) return; const projectedItemDurationMs = elapsedMs / (progressPercent / 100); const remainingCurrentMs = Math.max(0, projectedItemDurationMs - elapsedMs); const remainingMs = remainingCurrentMs + projectedItemDurationMs * remainingPendingCountRef.current; - const estimatedSeconds = Math.max(1, Math.round(remainingMs / 1000)); - setEstimatedTimeRemainingSec(estimatedSeconds); + setEstimatedTimeRemainingSec(Math.max(1, Math.round(remainingMs / 1000))); }); logger.info('Processing batch item', { From a461994c00ccf97f66ca76cc1138e4cb864d161d Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 7 May 2026 16:26:49 -0300 Subject: [PATCH 02/10] feat: add TranscriptMediaPlayer component and related functionality --- index.html | 4 +- src/main/index.ts | 3 + src/main/ipc/index.ts | 41 ++++ src/main/services/whisper.ts | 5 + src/main/utils/media-protocol.ts | 165 +++++++++++++ src/preload/index.ts | 1 + .../layout/RightPanel/RightPanel.tsx | 4 +- src/renderer/contexts/AppContext.tsx | 3 +- .../__tests__/useBatchQueue.test.ts | 6 +- .../OutputDisplay/OutputDisplay.tsx | 63 ++++- .../__tests__/OutputDisplay.test.tsx | 144 +++++++++++ .../TranscriptMediaPlayer.css | 65 +++++ .../TranscriptMediaPlayer.tsx | 202 +++++++++++++++ .../__tests__/TranscriptMediaPlayer.test.tsx | 229 ++++++++++++++++++ .../components/TranscriptMediaPlayer/index.ts | 2 + .../TranscriptionContent.css | 67 +++++ .../TranscriptionContent.tsx | 90 ++++++- .../transcription/components/index.ts | 1 + .../transcription/hooks/useBatchQueue.ts | 4 +- .../__tests__/transcriptSegments.test.ts | 44 ++++ .../transcription/utils/transcriptSegments.ts | 79 ++++++ .../services/__tests__/electronAPI.test.ts | 7 + src/renderer/services/electronAPI.ts | 6 + src/renderer/test/electronAPIMocks.ts | 6 + src/renderer/test/setup.ts | 1 + src/renderer/types/electron.d.ts | 2 + src/shared/types.ts | 9 + 27 files changed, 1238 insertions(+), 15 deletions(-) create mode 100644 src/main/utils/media-protocol.ts create mode 100644 src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css create mode 100644 src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx create mode 100644 src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx create mode 100644 src/renderer/features/transcription/components/TranscriptMediaPlayer/index.ts create mode 100644 src/renderer/features/transcription/utils/__tests__/transcriptSegments.test.ts create mode 100644 src/renderer/features/transcription/utils/transcriptSegments.ts diff --git a/index.html b/index.html index 6570561..004d7b5 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ + content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' blob: whisperdesk-media:"> WhisperDesk @@ -14,4 +14,4 @@ - \ No newline at end of file + diff --git a/src/main/index.ts b/src/main/index.ts index f8aaa83..02d9e69 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,9 +5,11 @@ import { registerIpcHandlers } from './ipc'; import { initAnalytics, trackEvent, AnalyticsEvents } from './services/analytics'; import { initAutoUpdater, checkForUpdates } from './services/auto-updater'; import { safeSend } from './utils/safe-send'; +import { registerMediaProtocolHandler, registerMediaProtocolScheme } from './utils/media-protocol'; import packageJson from '../../package.json'; initAnalytics(); +registerMediaProtocolScheme(); const APP_DISPLAY_NAME = 'WhisperDesk'; const APP_USER_MODEL_ID = 'com.whisperdesk.app'; @@ -251,6 +253,7 @@ const createWindow = () => { }; app.on('ready', () => { + registerMediaProtocolHandler(); createWindow(); if (!isDev) { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index aa0ce6d..dd687a0 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -17,12 +17,21 @@ import { generateMarkdownDocument, } from '../utils/export-helper'; import { generateFileFingerprint } from '../utils/media-info'; +import { createMediaProtocolUrl } from '../utils/media-protocol'; import { safeSend } from '../utils/safe-send'; import { trackEvent, AnalyticsEvents } from '../services/analytics'; import { SUPPORTED_EXTENSIONS } from '../../shared/types'; import type { TranscriptionOptions, SaveFileOptions } from '../../shared/types'; const OPEN_DIALOG_MEDIA_EXTENSIONS = [...SUPPORTED_EXTENSIONS]; +const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', 'flv', 'm4v']); + +function getSupportedMediaExtension(filePath: string): string | null { + const extension = path.extname(filePath).replace('.', '').toLowerCase(); + return SUPPORTED_EXTENSIONS.includes(extension as (typeof SUPPORTED_EXTENSIONS)[number]) + ? extension + : null; +} export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { ipcMain.handle('dialog:openFile', async () => { @@ -115,6 +124,38 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { } }); + ipcMain.handle('file:getMediaSource', async (_event, filePath: string) => { + try { + if (typeof filePath !== 'string' || filePath.trim().length === 0) { + return { success: false, error: 'Invalid file path' }; + } + + if (!path.isAbsolute(filePath)) { + return { success: false, error: 'Invalid file path' }; + } + + const resolvedPath = path.resolve(filePath); + const extension = getSupportedMediaExtension(resolvedPath); + if (!extension) { + return { success: false, error: 'Unsupported media file' }; + } + + try { + await fs.promises.access(resolvedPath, fs.constants.R_OK); + } catch { + return { success: false, error: 'File not found' }; + } + + return { + success: true, + url: createMediaProtocolUrl(resolvedPath), + mediaType: VIDEO_EXTENSIONS.has(extension) ? 'video' : 'audio', + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + ipcMain.handle('models:list', async () => { const models = listModels(); return { models }; diff --git a/src/main/services/whisper.ts b/src/main/services/whisper.ts index 7acf4da..db99bb8 100644 --- a/src/main/services/whisper.ts +++ b/src/main/services/whisper.ts @@ -93,6 +93,8 @@ const MODEL_ALIASES: Record = { turbo: 'large-v3-turbo', }; +const SUBTITLE_MAX_SEGMENT_CHARS = 80; + const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; export function getWhisperBinaryPath(): string { @@ -508,6 +510,9 @@ export function transcribe( '--output-txt', // Output plain text '--output-vtt', // Output VTT subtitles '--no-timestamps', // Don't print timestamps in main output (we use VTT) + '--max-len', + String(SUBTITLE_MAX_SEGMENT_CHARS), + '--split-on-word', '-pp', // Print progress '-of', outputBase, diff --git a/src/main/utils/media-protocol.ts b/src/main/utils/media-protocol.ts new file mode 100644 index 0000000..b886150 --- /dev/null +++ b/src/main/utils/media-protocol.ts @@ -0,0 +1,165 @@ +import { protocol } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import { Readable } from 'stream'; + +export const MEDIA_PROTOCOL = 'whisperdesk-media'; + +const mediaSources = new Map(); +let protocolHandlerRegistered = false; + +interface ByteRange { + start: number; + end: number; +} + +function getContentType(filePath: string): string { + const extension = path.extname(filePath).toLowerCase(); + const contentTypes: Record = { + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.m4a': 'audio/mp4', + '.flac': 'audio/flac', + '.ogg': 'audio/ogg', + '.opus': 'audio/ogg', + '.oga': 'audio/ogg', + '.amr': 'audio/amr', + '.wma': 'audio/x-ms-wma', + '.aac': 'audio/aac', + '.aiff': 'audio/aiff', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.avi': 'video/x-msvideo', + '.mkv': 'video/x-matroska', + '.webm': 'video/webm', + '.wmv': 'video/x-ms-wmv', + '.flv': 'video/x-flv', + '.m4v': 'video/x-m4v', + }; + + return contentTypes[extension] ?? 'application/octet-stream'; +} + +function parseRangeHeader(rangeHeader: string | null, fileSize: number): ByteRange | null { + if (!rangeHeader) { + return null; + } + + const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/); + if (!match) { + return null; + } + + const startValue = match[1] ?? ''; + const endValue = match[2] ?? ''; + + if (!startValue && !endValue) { + return null; + } + + if (!startValue) { + const suffixLength = Number(endValue); + if (!Number.isFinite(suffixLength) || suffixLength <= 0) { + return null; + } + + return { + start: Math.max(0, fileSize - suffixLength), + end: fileSize - 1, + }; + } + + const start = Number(startValue); + const end = endValue ? Number(endValue) : fileSize - 1; + + if ( + !Number.isFinite(start) || + !Number.isFinite(end) || + start < 0 || + end < start || + start >= fileSize + ) { + return null; + } + + return { + start, + end: Math.min(end, fileSize - 1), + }; +} + +async function createMediaResponse(request: Request, filePath: string): Promise { + const stats = await fs.promises.stat(filePath); + const fileSize = stats.size; + const contentType = getContentType(filePath); + const range = parseRangeHeader(request.headers.get('range'), fileSize); + + if (request.headers.has('range') && !range) { + return new Response(null, { + status: 416, + headers: { + 'Accept-Ranges': 'bytes', + 'Content-Range': `bytes */${fileSize}`, + }, + }); + } + + const start = range?.start ?? 0; + const end = range?.end ?? Math.max(0, fileSize - 1); + const contentLength = fileSize === 0 ? 0 : end - start + 1; + const stream = + request.method === 'HEAD' + ? null + : (Readable.toWeb(fs.createReadStream(filePath, { start, end })) as ReadableStream); + + return new Response(stream, { + status: range ? 206 : 200, + headers: { + 'Accept-Ranges': 'bytes', + 'Content-Length': String(contentLength), + 'Content-Type': contentType, + ...(range ? { 'Content-Range': `bytes ${start}-${end}/${fileSize}` } : {}), + }, + }); +} + +export function registerMediaProtocolScheme(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: MEDIA_PROTOCOL, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + }, + }, + ]); +} + +export function registerMediaProtocolHandler(): void { + if (protocolHandlerRegistered) { + return; + } + + protocol.handle(MEDIA_PROTOCOL, async (request) => { + const url = new URL(request.url); + const token = url.hostname || url.pathname.replace(/^\//, ''); + const filePath = mediaSources.get(token); + + if (!filePath) { + return new Response('Media source not found', { status: 404 }); + } + + return createMediaResponse(request, filePath); + }); + + protocolHandlerRegistered = true; +} + +export function createMediaProtocolUrl(filePath: string): string { + const resolvedPath = path.resolve(filePath); + const token = crypto.randomUUID(); + mediaSources.set(token, resolvedPath); + return `${MEDIA_PROTOCOL}://${token}`; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index abbe5e0..dcae448 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getFileInfo: (filePath: string) => ipcRenderer.invoke('file:getInfo', filePath), getPathForFile: (file: File) => webUtils.getPathForFile(file), + getMediaSource: (filePath: string) => ipcRenderer.invoke('file:getMediaSource', filePath), listModels: () => ipcRenderer.invoke('models:list'), getGpuStatus: () => ipcRenderer.invoke('models:gpuStatus'), diff --git a/src/renderer/components/layout/RightPanel/RightPanel.tsx b/src/renderer/components/layout/RightPanel/RightPanel.tsx index 2de6872..c53b059 100644 --- a/src/renderer/components/layout/RightPanel/RightPanel.tsx +++ b/src/renderer/components/layout/RightPanel/RightPanel.tsx @@ -12,7 +12,8 @@ function RightPanel(): React.JSX.Element { selectHistoryItem, removeHistoryItem, } = useAppHistory(); - const { transcription, copySuccess, handleSave, handleCopy } = useAppTranscription(); + const { transcription, selectedFile, copySuccess, handleSave, handleCopy } = + useAppTranscription(); if (showHistory) { return ( @@ -32,6 +33,7 @@ function RightPanel(): React.JSX.Element {
{ + onFirstComplete: (id, text, file) => { setSelectedQueueItemId(id); setTranscription(text); + setSelectedFile(file); }, }); diff --git a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts index 02a76c2..4fb7b3e 100644 --- a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts +++ b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts @@ -606,7 +606,11 @@ describe('useBatchQueue', () => { expect(mockOnFirstComplete).toHaveBeenCalledTimes(1); expect(mockOnFirstComplete).toHaveBeenCalledWith( result.current.queue[0]!.id, - 'Transcribed text' + 'Transcribed text', + expect.objectContaining({ + name: 'audio1.mp3', + path: '/path/to/audio1.mp3', + }) ); }); diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx index 02dd9b0..f569de9 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx @@ -1,16 +1,19 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import './OutputDisplay.css'; -import type { OutputFormat } from '../../../../types'; +import type { OutputFormat, SelectedFile } from '../../../../types'; import { TranscriptionToolbar } from '../TranscriptionToolbar'; import { TranscriptionSearch } from '../TranscriptionSearch'; import { TranscriptionContent } from '../TranscriptionContent'; +import { TranscriptMediaPlayer } from '../TranscriptMediaPlayer'; +import { parseTranscriptSegments, type TranscriptSegment } from '../../utils/transcriptSegments'; export interface OutputDisplayProps { text: string; onSave: (format: OutputFormat) => void; onCopy: () => void; copySuccess: boolean; + selectedFile?: SelectedFile | null; } interface SearchMatch { @@ -23,34 +26,57 @@ function OutputDisplay({ onSave, onCopy, copySuccess, + selectedFile = null, }: OutputDisplayProps): React.JSX.Element { const [showSearch, setShowSearch] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + const [playbackTime, setPlaybackTime] = useState(0); + const mediaRef = useRef(null); const hasText = text.length > 0; const wordCount = hasText ? text.trim().split(/\s+/).length : 0; const charCount = hasText ? text.length : 0; + const segments = useMemo(() => parseTranscriptSegments(text), [text]); + const hasSegments = segments.length > 0; + const searchableText = hasSegments ? segments.map((segment) => segment.text).join('\n') : text; const matches = useMemo((): SearchMatch[] => { - if (!searchQuery || !text) return []; + if (!searchQuery || !searchableText) return []; const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(escapedQuery, 'gi'); const results: SearchMatch[] = []; let match: RegExpExecArray | null; - while ((match = regex.exec(text)) !== null) { + while ((match = regex.exec(searchableText)) !== null) { results.push({ start: match.index, end: match.index + match[0].length }); } return results; - }, [searchQuery, text]); + }, [searchQuery, searchableText]); useEffect(() => { setCurrentMatchIndex(0); }, [searchQuery]); + useEffect(() => { + if (currentMatchIndex >= matches.length) { + setCurrentMatchIndex(0); + } + }, [currentMatchIndex, matches.length]); + + const activeSegmentIndex = useMemo((): number | null => { + if (!hasSegments) { + return null; + } + + const activeSegment = segments.find( + (segment) => playbackTime >= segment.startSec && playbackTime < segment.endSec + ); + return activeSegment?.index ?? null; + }, [hasSegments, playbackTime, segments]); + useEffect(() => { const handleKeyDown = (e: globalThis.KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key === 'f' && hasText) { @@ -96,7 +122,7 @@ function OutputDisplay({ }; const highlightedText = useMemo((): React.JSX.Element[] | null => { - if (!searchQuery || !text || matches.length === 0) return null; + if (hasSegments || !searchQuery || !text || matches.length === 0) return null; const parts: React.JSX.Element[] = []; let lastIndex = 0; @@ -122,7 +148,18 @@ function OutputDisplay({ } return parts; - }, [text, searchQuery, matches, currentMatchIndex]); + }, [hasSegments, text, searchQuery, matches, currentMatchIndex]); + + const handleSegmentClick = useCallback((segment: TranscriptSegment): void => { + const media = mediaRef.current; + if (!media) { + return; + } + + media.currentTime = segment.startSec; + setPlaybackTime(segment.startSec); + void media.play().catch(() => {}); + }, []); return (
@@ -149,12 +186,24 @@ function OutputDisplay({ /> )} + {hasText && hasSegments && selectedFile && ( + + )} +
); diff --git a/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx b/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx index 1ddc94a..4da6c83 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx @@ -2,12 +2,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { OutputDisplay } from '@/features/transcription'; import { MOCK_TRANSCRIPTION_RESULT } from '@/test/fixtures'; +import { createFullElectronAPIMock } from '@/test/electronAPIMocks'; +import type { ElectronAPI } from '@/types/electron'; describe('OutputDisplay', () => { const mockTranscriptionText = MOCK_TRANSCRIPTION_RESULT.text ?? ''; beforeEach(() => { vi.clearAllMocks(); + (window as unknown as { electronAPI?: ElectronAPI }).electronAPI = undefined; }); it('should render transcription text', () => { @@ -806,4 +809,145 @@ describe('OutputDisplay', () => { const searchInput = screen.getByPlaceholderText(/search/i); expect(searchInput).toBeInTheDocument(); }); + + it('should render timestamped segments when VTT is present', () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + + render( + 00:00:03.000 +First segment + +00:00:04.000 --> 00:00:06.000 +Second segment`} + onSave={onSave} + onCopy={onCopy} + copySuccess={false} + /> + ); + + expect(screen.getByLabelText('Timestamped transcript')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Play from 00:00:01.000/i })).toBeInTheDocument(); + expect(screen.getByText('Second segment')).toBeInTheDocument(); + }); + + it('should seek and play media when a timestamp is clicked', async () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + const api = createFullElectronAPIMock(); + window.electronAPI = api; + const playSpy = vi + .spyOn(window.HTMLMediaElement.prototype, 'play') + .mockResolvedValue(undefined); + + render( + 00:00:03.000 +First segment`} + selectedFile={{ name: 'file.mp3', path: '/path/file.mp3' }} + onSave={onSave} + onCopy={onCopy} + copySuccess={false} + /> + ); + + await waitFor(() => { + expect(api.getMediaSource).toHaveBeenCalledWith('/path/file.mp3'); + }); + + fireEvent.click(screen.getByRole('button', { name: /Play from 00:00:01.000/i })); + + expect(playSpy).toHaveBeenCalled(); + playSpy.mockRestore(); + }); + + it('should highlight the active segment from media time updates', async () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + window.electronAPI = createFullElectronAPIMock(); + + render( + 00:00:03.000 +First segment + +00:00:04.000 --> 00:00:06.000 +Second segment`} + selectedFile={{ name: 'file.mp3', path: '/path/file.mp3' }} + onSave={onSave} + onCopy={onCopy} + copySuccess={false} + /> + ); + + const audio = await screen.findByLabelText('Selected audio preview'); + Object.defineProperty(audio, 'currentTime', { value: 4.5, configurable: true }); + fireEvent.timeUpdate(audio); + + await waitFor(() => { + expect(screen.getByText('Second segment').closest('.transcript-segment')).toHaveClass( + 'active' + ); + }); + }); + + it('should show unavailable media state while keeping transcript visible', async () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + const api = createFullElectronAPIMock(); + api.getMediaSource = vi.fn().mockResolvedValue({ success: false, error: 'File not found' }); + window.electronAPI = api; + + render( + 00:00:03.000 +First segment`} + selectedFile={{ name: 'missing.mp3', path: '/path/missing.mp3' }} + onSave={onSave} + onCopy={onCopy} + copySuccess={false} + /> + ); + + expect(screen.getByText('First segment')).toBeInTheDocument(); + expect(await screen.findByText('File not found')).toBeInTheDocument(); + }); + + it('should search and highlight matches in timestamped segments', async () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + + render( + 00:00:03.000 +alpha beta + +00:00:04.000 --> 00:00:06.000 +beta gamma`} + onSave={onSave} + onCopy={onCopy} + copySuccess={false} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /search/i })); + const searchInput = await screen.findByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'beta' } }); + + await waitFor(() => { + expect(screen.getByText(/1 of 2/i)).toBeInTheDocument(); + }); + expect(screen.getAllByText('beta')).toHaveLength(2); + }); }); diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css new file mode 100644 index 0000000..76cd9d7 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css @@ -0,0 +1,65 @@ +.transcript-media-player { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.transcript-media-status, +.transcript-media-unavailable { + color: var(--text-secondary); + font-size: 0.85rem; +} + +.transcript-media-unavailable { + flex-direction: row; + align-items: center; + gap: 8px; +} + +.transcript-video-preview { + width: 100%; + max-height: 240px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-deep); +} + +.transcript-media-player audio { + display: none; +} + +.transcript-media-controls { + display: grid; + grid-template-columns: auto auto minmax(120px, 1fr) auto; + align-items: center; + gap: 10px; +} + +.transcript-media-time { + color: var(--text-muted); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + min-width: 42px; +} + +.transcript-media-seek { + width: 100%; + accent-color: var(--accent); +} + +.transcript-media-seek:disabled { + opacity: 0.5; +} + +@media (max-width: 640px) { + .transcript-media-controls { + grid-template-columns: auto auto 1fr auto; + } + + .transcript-video-preview { + max-height: 180px; + } +} diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx new file mode 100644 index 0000000..7e2bda6 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useState, type RefObject } from 'react'; +import { AlertCircle, Pause, Play } from 'lucide-react'; +import { Button } from '../../../../components/ui'; +import { getMediaSource } from '../../../../services/electronAPI'; +import type { MediaSourceResult, SelectedFile } from '../../../../types'; +import './TranscriptMediaPlayer.css'; + +export interface TranscriptMediaPlayerProps { + selectedFile: SelectedFile | null; + mediaRef: RefObject; + onPlaybackTimeChange: (timeSec: number) => void; +} + +function formatPlaybackTime(value: number): string { + if (!Number.isFinite(value) || value < 0) { + return '00:00'; + } + + const totalSeconds = Math.floor(value); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String( + seconds + ).padStart(2, '0')}`; + } + + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +function TranscriptMediaPlayer({ + selectedFile, + mediaRef, + onPlaybackTimeChange, +}: TranscriptMediaPlayerProps): React.JSX.Element | null { + const [source, setSource] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + useEffect(() => { + let isMounted = true; + + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + onPlaybackTimeChange(0); + + if (!selectedFile?.path) { + setSource(null); + setIsLoading(false); + return; + } + + setIsLoading(true); + void getMediaSource(selectedFile.path) + .then((result) => { + if (isMounted) { + setSource(result); + } + }) + .catch((error) => { + if (isMounted) { + setSource({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }) + .finally(() => { + if (isMounted) { + setIsLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, [onPlaybackTimeChange, selectedFile?.path]); + + if (!selectedFile) { + return null; + } + + const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0; + + const handlePlayToggle = (): void => { + const media = mediaRef.current; + if (!media) return; + + if (media.paused) { + void media.play().catch(() => { + setIsPlaying(false); + }); + return; + } + + media.pause(); + }; + + const handleSeek = (event: React.ChangeEvent): void => { + const media = mediaRef.current; + if (!media) return; + + const nextTime = Number(event.target.value); + media.currentTime = Number.isFinite(nextTime) ? nextTime : 0; + setCurrentTime(media.currentTime); + onPlaybackTimeChange(media.currentTime); + }; + + const handleTimeUpdate = (event: React.SyntheticEvent): void => { + const nextTime = event.currentTarget.currentTime; + setCurrentTime(nextTime); + onPlaybackTimeChange(nextTime); + }; + + const handleLoadedMetadata = (event: React.SyntheticEvent): void => { + const nextDuration = event.currentTarget.duration; + setDuration(Number.isFinite(nextDuration) ? nextDuration : 0); + }; + + const handleEnded = (): void => { + setIsPlaying(false); + }; + + const setMediaElement = (element: HTMLMediaElement | null): void => { + mediaRef.current = element; + }; + + if (isLoading) { + return ( +
+ Loading media preview... +
+ ); + } + + if (!source?.success || !source.url || !source.mediaType) { + return ( +
+
+ ); + } + + const mediaProps = { + src: source.url, + preload: 'metadata', + onTimeUpdate: handleTimeUpdate, + onLoadedMetadata: handleLoadedMetadata, + onPlay: () => setIsPlaying(true), + onPause: () => setIsPlaying(false), + onEnded: handleEnded, + }; + + return ( +
+ {source.mediaType === 'video' && ( +
+ ); +} + +export { TranscriptMediaPlayer }; diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx b/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx new file mode 100644 index 0000000..69ebf29 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TranscriptMediaPlayer } from '../TranscriptMediaPlayer'; +import { createFullElectronAPIMock } from '@/test/electronAPIMocks'; +import type { ElectronAPI } from '@/types/electron'; + +describe('TranscriptMediaPlayer', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window as unknown as { electronAPI?: ElectronAPI }).electronAPI = createFullElectronAPIMock(); + }); + + it('renders nothing without a selected file', () => { + const mediaRef = React.createRef(); + const onPlaybackTimeChange = vi.fn(); + + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + expect(onPlaybackTimeChange).toHaveBeenCalledWith(0); + }); + + it('shows a loading state while resolving the media source', async () => { + const mediaRef = React.createRef(); + const onPlaybackTimeChange = vi.fn(); + let resolveSource: (value: { success: boolean; url: string; mediaType: 'audio' }) => void; + window.electronAPI = { + ...createFullElectronAPIMock(), + getMediaSource: vi.fn( + (_filePath: string) => + new Promise<{ success: boolean; url: string; mediaType: 'audio' }>((resolve) => { + resolveSource = resolve; + }) + ), + }; + + render( + + ); + + expect(await screen.findByText('Loading media preview...')).toBeInTheDocument(); + resolveSource!({ + success: true, + url: 'whisperdesk-media://test-audio', + mediaType: 'audio', + }); + expect(await screen.findByLabelText('Selected audio preview')).toBeInTheDocument(); + }); + + it('renders unavailable state for missing media sources', async () => { + const mediaRef = React.createRef(); + window.electronAPI = { + ...createFullElectronAPIMock(), + getMediaSource: vi.fn().mockResolvedValue({ success: false }), + }; + + render( + + ); + + expect(await screen.findByText('Media preview unavailable')).toBeInTheDocument(); + }); + + it('handles files without a usable path', async () => { + const mediaRef = React.createRef(); + + render( + + ); + + expect(await screen.findByText('Media preview unavailable')).toBeInTheDocument(); + }); + + it('shows media source errors when source resolution rejects', async () => { + const mediaRef = React.createRef(); + window.electronAPI = { + ...createFullElectronAPIMock(), + getMediaSource: vi.fn().mockRejectedValue(new Error('Preview blocked')), + }; + + render( + + ); + + expect(await screen.findByText('Preview blocked')).toBeInTheDocument(); + }); + + it('stringifies non-error media source rejections', async () => { + const mediaRef = React.createRef(); + window.electronAPI = { + ...createFullElectronAPIMock(), + getMediaSource: vi.fn().mockRejectedValue('Preview unavailable'), + }; + + render( + + ); + + expect(await screen.findByText('Preview unavailable')).toBeInTheDocument(); + }); + + it('renders video preview and updates duration from metadata', async () => { + const mediaRef = React.createRef(); + window.electronAPI = { + ...createFullElectronAPIMock(), + getMediaSource: vi.fn().mockResolvedValue({ + success: true, + url: 'whisperdesk-media://test-video', + mediaType: 'video', + }), + }; + + render( + + ); + + const video = await screen.findByLabelText('Selected video preview'); + Object.defineProperty(video, 'duration', { value: 3661, configurable: true }); + fireEvent.loadedMetadata(video); + + expect(await screen.findByText('01:01:01')).toBeInTheDocument(); + }); + + it('supports play, pause, seeking, time updates, and ended state', async () => { + const mediaRef = React.createRef(); + const onPlaybackTimeChange = vi.fn(); + const playSpy = vi + .spyOn(window.HTMLMediaElement.prototype, 'play') + .mockResolvedValue(undefined); + const pauseSpy = vi + .spyOn(window.HTMLMediaElement.prototype, 'pause') + .mockImplementation(() => {}); + + render( + + ); + + const audio = await screen.findByLabelText('Selected audio preview'); + Object.defineProperty(audio, 'duration', { value: 120, configurable: true }); + fireEvent.loadedMetadata(audio); + + const playButton = screen.getByRole('button', { name: 'Play preview' }); + fireEvent.click(playButton); + expect(playSpy).toHaveBeenCalled(); + + fireEvent.play(audio); + expect(screen.getByRole('button', { name: 'Pause preview' })).toBeInTheDocument(); + + Object.defineProperty(audio, 'paused', { value: false, configurable: true }); + fireEvent.click(screen.getByRole('button', { name: 'Pause preview' })); + expect(pauseSpy).toHaveBeenCalled(); + + const seek = screen.getByLabelText('Seek media preview'); + fireEvent.change(seek, { target: { value: '35' } }); + expect(onPlaybackTimeChange).toHaveBeenCalledWith(35); + + Object.defineProperty(audio, 'currentTime', { value: 65, configurable: true }); + fireEvent.timeUpdate(audio); + expect(onPlaybackTimeChange).toHaveBeenCalledWith(65); + expect(screen.getByText('01:05')).toBeInTheDocument(); + + Object.defineProperty(audio, 'currentTime', { value: -5, configurable: true }); + fireEvent.timeUpdate(audio); + expect(screen.getByText('00:00')).toBeInTheDocument(); + + fireEvent.ended(audio); + expect(screen.getByRole('button', { name: 'Play preview' })).toBeInTheDocument(); + + playSpy.mockRestore(); + pauseSpy.mockRestore(); + }); + + it('handles play rejections and invalid metadata values', async () => { + const mediaRef = React.createRef(); + vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('blocked')); + + render( + + ); + + const audio = await screen.findByLabelText('Selected audio preview'); + Object.defineProperty(audio, 'duration', { value: Number.NaN, configurable: true }); + fireEvent.loadedMetadata(audio); + fireEvent.click(screen.getByRole('button', { name: 'Play preview' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Play preview' })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/index.ts b/src/renderer/features/transcription/components/TranscriptMediaPlayer/index.ts new file mode 100644 index 0000000..bde5ce5 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/index.ts @@ -0,0 +1,2 @@ +export { TranscriptMediaPlayer } from './TranscriptMediaPlayer'; +export type { TranscriptMediaPlayerProps } from './TranscriptMediaPlayer'; diff --git a/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css index b7c7b6c..65dc859 100644 --- a/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css +++ b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css @@ -54,3 +54,70 @@ outline: 2px solid var(--accent); outline-offset: 1px; } + +.transcript-segments { + display: flex; + flex-direction: column; + gap: 8px; +} + +.transcript-segment { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 10px 12px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; +} + +.transcript-segment:hover { + background: var(--surface); + border-color: var(--border); +} + +.transcript-segment.active { + background: var(--accent-light); + border-color: var(--accent-border); +} + +.transcript-segment-timestamp { + width: 100%; + padding: 4px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.transcript-segment-timestamp:hover { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.transcript-segment-text { + margin: 0; + color: var(--text-primary); + font-size: 0.95rem; + line-height: 1.7; + -webkit-user-select: text; + user-select: text; + word-wrap: break-word; +} + +@media (max-width: 640px) { + .transcript-segment { + grid-template-columns: 1fr; + gap: 8px; + } + + .transcript-segment-timestamp { + width: fit-content; + } +} diff --git a/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx index 39f36e2..d0158a8 100644 --- a/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx +++ b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect } from 'react'; import { FileText } from 'lucide-react'; +import type { TranscriptSegment } from '../../utils/transcriptSegments'; import './TranscriptionContent.css'; export interface TranscriptionContentProps { @@ -8,6 +9,10 @@ export interface TranscriptionContentProps { highlightedText: React.JSX.Element[] | null; currentMatchIndex: number; matchCount: number; + segments?: TranscriptSegment[]; + activeSegmentIndex?: number | null; + searchQuery?: string; + onSegmentClick?: (segment: TranscriptSegment) => void; } function TranscriptionContent({ @@ -16,6 +21,10 @@ function TranscriptionContent({ highlightedText, currentMatchIndex, matchCount, + segments = [], + activeSegmentIndex = null, + searchQuery = '', + onSegmentClick, }: TranscriptionContentProps): React.JSX.Element { const contentRef = useRef(null); @@ -28,6 +37,61 @@ function TranscriptionContent({ } }, [currentMatchIndex, matchCount]); + useEffect(() => { + if (activeSegmentIndex === null || searchQuery || !contentRef.current) { + return; + } + + const activeSegment = contentRef.current.querySelector('.transcript-segment.active'); + if (activeSegment) { + activeSegment.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [activeSegmentIndex, searchQuery]); + + const renderSegmentText = ( + segmentText: string, + query: string, + matchCounter: { value: number } + ): React.ReactNode => { + if (!query) { + return segmentText; + } + + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedQuery, 'gi'); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(segmentText)) !== null) { + if (match.index > lastIndex) { + parts.push(segmentText.substring(lastIndex, match.index)); + } + + const globalMatchIndex = matchCounter.value; + parts.push( + + {segmentText.substring(match.index, match.index + match[0].length)} + + ); + matchCounter.value += 1; + lastIndex = match.index + match[0].length; + } + + if (lastIndex < segmentText.length) { + parts.push(segmentText.substring(lastIndex)); + } + + return parts; + }; + + const hasSegments = segments.length > 0; + const segmentMatchCounter = { value: 0 }; + return (
- {hasText ? ( + {hasText && hasSegments ? ( +
+ {segments.map((segment) => ( +
+ +

+ {renderSegmentText(segment.text, searchQuery, segmentMatchCounter)} +

+
+ ))} +
+ ) : hasText ? (
           {highlightedText || text}
         
diff --git a/src/renderer/features/transcription/components/index.ts b/src/renderer/features/transcription/components/index.ts index a744cdb..3bb84f7 100644 --- a/src/renderer/features/transcription/components/index.ts +++ b/src/renderer/features/transcription/components/index.ts @@ -7,6 +7,7 @@ export type { OutputDisplayProps } from './OutputDisplay'; export * from './TranscriptionToolbar'; export * from './TranscriptionSearch'; export * from './TranscriptionContent'; +export * from './TranscriptMediaPlayer'; export { FileQueue } from './FileQueue'; export type { FileQueueProps } from './FileQueue'; diff --git a/src/renderer/features/transcription/hooks/useBatchQueue.ts b/src/renderer/features/transcription/hooks/useBatchQueue.ts index 008b063..d5c491e 100644 --- a/src/renderer/features/transcription/hooks/useBatchQueue.ts +++ b/src/renderer/features/transcription/hooks/useBatchQueue.ts @@ -19,7 +19,7 @@ import { toUserFriendlyTranscriptionError } from '../utils/errorMessages'; interface UseBatchQueueOptions { settings: TranscriptionSettings; onHistoryAdd?: (item: HistoryItem) => void; - onFirstComplete?: (id: string, text: string) => void; + onFirstComplete?: (id: string, text: string, file: SelectedFile) => void; } interface UseBatchQueueReturn { @@ -537,7 +537,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur if (!hasCalledFirstCompleteRef.current && onFirstComplete && result.text) { hasCalledFirstCompleteRef.current = true; - onFirstComplete(item.id, result.text); + onFirstComplete(item.id, result.text, item.file); } return { diff --git a/src/renderer/features/transcription/utils/__tests__/transcriptSegments.test.ts b/src/renderer/features/transcription/utils/__tests__/transcriptSegments.test.ts new file mode 100644 index 0000000..f6fb485 --- /dev/null +++ b/src/renderer/features/transcription/utils/__tests__/transcriptSegments.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { parseTranscriptSegments, timestampToSeconds } from '../transcriptSegments'; + +describe('transcriptSegments', () => { + it('converts timestamps to seconds', () => { + expect(timestampToSeconds('00:00:12.500')).toBe(12.5); + expect(timestampToSeconds('01:02:03.250')).toBe(3723.25); + }); + + it('parses valid VTT with multiple cues', () => { + const segments = parseTranscriptSegments(`WEBVTT + +00:00:01.000 --> 00:00:03.500 +Hello there + +00:00:04.000 --> 00:00:05.000 +General Kenobi`); + + expect(segments).toHaveLength(2); + expect(segments[0]).toMatchObject({ + index: 0, + startSec: 1, + endSec: 3.5, + timestamp: '00:00:01.000 --> 00:00:03.500', + text: 'Hello there', + }); + expect(segments[1]?.text).toBe('General Kenobi'); + }); + + it('joins multi-line cue text', () => { + const segments = parseTranscriptSegments(`WEBVTT + +00:00:01.000 --> 00:00:04.000 +First line +second line`); + + expect(segments[0]?.text).toBe('First line second line'); + }); + + it('returns no segments for empty or invalid transcript text', () => { + expect(parseTranscriptSegments('')).toEqual([]); + expect(parseTranscriptSegments('Plain transcript without timestamps')).toEqual([]); + }); +}); diff --git a/src/renderer/features/transcription/utils/transcriptSegments.ts b/src/renderer/features/transcription/utils/transcriptSegments.ts new file mode 100644 index 0000000..4d78fdf --- /dev/null +++ b/src/renderer/features/transcription/utils/transcriptSegments.ts @@ -0,0 +1,79 @@ +export interface TranscriptSegment { + id: string; + index: number; + startSec: number; + endSec: number; + timestamp: string; + text: string; +} + +const TIMESTAMP_PATTERN = + /^(?\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(?\d{2}:\d{2}:\d{2}\.\d{3})/; + +export function timestampToSeconds(timestamp: string): number { + const [hours = '0', minutes = '0', seconds = '0'] = timestamp.split(':'); + const parsedHours = Number(hours); + const parsedMinutes = Number(minutes); + const parsedSeconds = Number(seconds); + + if (![parsedHours, parsedMinutes, parsedSeconds].every(Number.isFinite)) { + return 0; + } + + return parsedHours * 3600 + parsedMinutes * 60 + parsedSeconds; +} + +export function parseTranscriptSegments(transcript: string): TranscriptSegment[] { + if (!transcript.trim()) { + return []; + } + + const lines = transcript.split(/\r?\n/); + const segments: TranscriptSegment[] = []; + let currentTimestamp = ''; + let currentStartSec = 0; + let currentEndSec = 0; + let currentText: string[] = []; + + const flushSegment = (): void => { + const text = currentText.join(' ').replace(/\s+/g, ' ').trim(); + if (!currentTimestamp || !text) { + currentText = []; + return; + } + + const index = segments.length; + segments.push({ + id: `segment-${index}-${currentStartSec.toFixed(3)}`, + index, + startSec: currentStartSec, + endSec: currentEndSec, + timestamp: currentTimestamp, + text, + }); + currentText = []; + }; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line === 'WEBVTT' || /^NOTE\b/.test(line)) { + continue; + } + + const match = line.match(TIMESTAMP_PATTERN); + if (match?.groups) { + flushSegment(); + currentTimestamp = line; + currentStartSec = timestampToSeconds(match.groups.start ?? '00:00:00.000'); + currentEndSec = timestampToSeconds(match.groups.end ?? '00:00:00.000'); + continue; + } + + if (currentTimestamp) { + currentText.push(line); + } + } + + flushSegment(); + return segments; +} diff --git a/src/renderer/services/__tests__/electronAPI.test.ts b/src/renderer/services/__tests__/electronAPI.test.ts index ef4c3cc..1d847a0 100644 --- a/src/renderer/services/__tests__/electronAPI.test.ts +++ b/src/renderer/services/__tests__/electronAPI.test.ts @@ -3,6 +3,7 @@ import { isElectronAvailable, openFileDialog, getFileInfo, + getMediaSource, getPathForFile, saveFile, startTranscription, @@ -52,6 +53,9 @@ describe('electronAPI wrapper', () => { const info = await getFileInfo('/missing'); expect(info).toBeNull(); + const mediaSource = await getMediaSource('/missing'); + expect(mediaSource.success).toBe(false); + const pathForFile = getPathForFile(new File([''], 'test.mp3')); expect(pathForFile).toBeUndefined(); @@ -151,6 +155,9 @@ describe('electronAPI wrapper', () => { await getFileInfo('/path/file.mp3'); expect(api.getFileInfo).toHaveBeenCalledWith('/path/file.mp3'); + await getMediaSource('/path/file.mp3'); + expect(api.getMediaSource).toHaveBeenCalledWith('/path/file.mp3'); + const testFile = new File([''], 'test.mp3'); getPathForFile(testFile); expect(api.getPathForFile).toHaveBeenCalledWith(testFile); diff --git a/src/renderer/services/electronAPI.ts b/src/renderer/services/electronAPI.ts index e9395f3..47b77d9 100644 --- a/src/renderer/services/electronAPI.ts +++ b/src/renderer/services/electronAPI.ts @@ -4,6 +4,7 @@ import type { TranscriptionProgress, SaveFileOptions, SaveFileResult, + MediaSourceResult, GpuInfo, ModelInfo, SelectedFile, @@ -32,6 +33,11 @@ export async function getFileInfo(filePath: string): Promise { + const result = await window.electronAPI?.getMediaSource(filePath); + return result ?? { success: false, error: 'Electron API not available' }; +} + export function getPathForFile(file: File): string | undefined { return window.electronAPI?.getPathForFile(file); } diff --git a/src/renderer/test/electronAPIMocks.ts b/src/renderer/test/electronAPIMocks.ts index 689e50e..dbefefa 100644 --- a/src/renderer/test/electronAPIMocks.ts +++ b/src/renderer/test/electronAPIMocks.ts @@ -8,6 +8,7 @@ export const createDefaultElectronAPIMock = (): ElectronAPI => ({ saveFile: vi.fn().mockResolvedValue({ success: false, error: 'Not implemented' }), getFileInfo: vi.fn().mockResolvedValue(null), getPathForFile: vi.fn().mockReturnValue('/path/to/file'), + getMediaSource: vi.fn().mockResolvedValue({ success: false, error: 'Not implemented' }), listModels: vi.fn().mockResolvedValue({ models: [] }), deleteModel: vi.fn().mockResolvedValue({ success: true }), checkFFmpeg: vi.fn().mockResolvedValue(true), @@ -53,6 +54,11 @@ export const createFullElectronAPIMock = (): ElectronAPI => ({ saveFile: vi.fn().mockResolvedValue({ success: true, filePath: '/saved.txt' }), getFileInfo: vi.fn().mockResolvedValue({ name: 'file.mp3', path: '/path/file.mp3', size: 10 }), getPathForFile: vi.fn().mockReturnValue('/path/file.mp3'), + getMediaSource: vi.fn().mockResolvedValue({ + success: true, + url: 'whisperdesk-media://test-audio', + mediaType: 'audio', + }), listModels: vi.fn().mockResolvedValue({ models: [] }), deleteModel: vi.fn().mockResolvedValue({ success: true }), checkFFmpeg: vi.fn().mockResolvedValue(true), diff --git a/src/renderer/test/setup.ts b/src/renderer/test/setup.ts index da91729..9d3541d 100644 --- a/src/renderer/test/setup.ts +++ b/src/renderer/test/setup.ts @@ -10,6 +10,7 @@ const mockElectronAPI: ElectronAPI = { saveFile: vi.fn().mockResolvedValue({ success: false, error: 'Not implemented' }), getFileInfo: vi.fn().mockResolvedValue(null), getPathForFile: vi.fn().mockReturnValue('/path/to/file'), + getMediaSource: vi.fn().mockResolvedValue({ success: false, error: 'Not implemented' }), listModels: vi.fn().mockResolvedValue({ models: [] }), deleteModel: vi.fn().mockResolvedValue({ success: true }), checkFFmpeg: vi.fn().mockResolvedValue(true), diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 07d46d6..94323c5 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -5,6 +5,7 @@ import type { ModelDownloadProgress, SaveFileOptions, SaveFileResult, + MediaSourceResult, GpuInfo, ModelInfo, SelectedFile, @@ -37,6 +38,7 @@ export interface ElectronAPI { saveFile: (options: SaveFileOptions) => Promise; getFileInfo: (filePath: string) => Promise; getPathForFile: (file: File) => string; + getMediaSource: (filePath: string) => Promise; listModels: () => Promise; deleteModel: (modelName: string) => Promise<{ success: boolean; error?: string }>; getGpuStatus: () => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index c52b2b9..6e675e1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -124,6 +124,15 @@ export interface SaveFileResult { error?: string; } +export type MediaSourceType = 'audio' | 'video'; + +export interface MediaSourceResult { + success: boolean; + url?: string; + mediaType?: MediaSourceType; + error?: string; +} + export interface AppInfo { isDev: boolean; version: string; From 69afff196044420dafb329424ccacbcde6cd9552 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 7 May 2026 16:35:04 -0300 Subject: [PATCH 03/10] feat: add volume, mute, and playback speed controls to TranscriptMediaPlayer --- .../TranscriptMediaPlayer.css | 44 ++++++++- .../TranscriptMediaPlayer.tsx | 94 ++++++++++++++++++- .../__tests__/TranscriptMediaPlayer.test.tsx | 38 ++++++++ 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css index 76cd9d7..ba53aba 100644 --- a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css @@ -33,7 +33,7 @@ .transcript-media-controls { display: grid; - grid-template-columns: auto auto minmax(120px, 1fr) auto; + grid-template-columns: auto auto minmax(120px, 1fr) auto minmax(120px, 180px) auto; align-items: center; gap: 10px; } @@ -54,9 +54,49 @@ opacity: 0.5; } +.transcript-media-volume { + display: grid; + grid-template-columns: auto minmax(72px, 1fr); + align-items: center; + gap: 6px; +} + +.transcript-media-volume-slider { + width: 100%; + accent-color: var(--accent); +} + +.transcript-media-speed { + height: 32px; + min-width: 72px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-primary); + font: inherit; + font-size: 0.8rem; + cursor: pointer; +} + +.transcript-media-speed:hover { + background: var(--surface-hover); + border-color: var(--border-hover); +} + @media (max-width: 640px) { .transcript-media-controls { - grid-template-columns: auto auto 1fr auto; + grid-template-columns: auto auto 1fr auto auto; + row-gap: 8px; + } + + .transcript-media-volume { + grid-column: 1 / 4; + } + + .transcript-media-speed { + grid-column: 4 / 6; + width: 100%; } .transcript-video-preview { diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx index 7e2bda6..37c330d 100644 --- a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, type RefObject } from 'react'; -import { AlertCircle, Pause, Play } from 'lucide-react'; +import { AlertCircle, Pause, Play, Volume2, VolumeX } from 'lucide-react'; import { Button } from '../../../../components/ui'; import { getMediaSource } from '../../../../services/electronAPI'; import type { MediaSourceResult, SelectedFile } from '../../../../types'; @@ -11,6 +11,10 @@ export interface TranscriptMediaPlayerProps { onPlaybackTimeChange: (timeSec: number) => void; } +const PLAYBACK_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; +const DEFAULT_VOLUME = 1; +const RESTORED_VOLUME = 0.8; + function formatPlaybackTime(value: number): string { if (!Number.isFinite(value) || value < 0) { return '00:00'; @@ -40,6 +44,9 @@ function TranscriptMediaPlayer({ const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(DEFAULT_VOLUME); + const [isMuted, setIsMuted] = useState(false); + const [playbackRate, setPlaybackRate] = useState(1); useEffect(() => { let isMounted = true; @@ -111,6 +118,53 @@ function TranscriptMediaPlayer({ onPlaybackTimeChange(media.currentTime); }; + const applyVolume = (media: HTMLMediaElement, nextVolume: number, nextMuted: boolean): void => { + media.volume = nextVolume; + media.muted = nextMuted; + }; + + const handleMuteToggle = (): void => { + const media = mediaRef.current; + const shouldUnmute = isMuted || volume === 0; + const nextVolume = shouldUnmute && volume === 0 ? RESTORED_VOLUME : volume; + const nextMuted = !shouldUnmute; + + setVolume(nextVolume); + setIsMuted(nextMuted); + + if (media) { + applyVolume(media, nextVolume, nextMuted); + } + }; + + const handleVolumeChange = (event: React.ChangeEvent): void => { + const media = mediaRef.current; + const nextVolume = Math.min(1, Math.max(0, Number(event.target.value))); + const nextMuted = nextVolume === 0; + + setVolume(nextVolume); + setIsMuted(nextMuted); + + if (media) { + applyVolume(media, nextVolume, nextMuted); + } + }; + + const handlePlaybackRateChange = (event: React.ChangeEvent): void => { + const media = mediaRef.current; + const nextPlaybackRate = Number(event.target.value); + + if (!Number.isFinite(nextPlaybackRate)) { + return; + } + + setPlaybackRate(nextPlaybackRate); + + if (media) { + media.playbackRate = nextPlaybackRate; + } + }; + const handleTimeUpdate = (event: React.SyntheticEvent): void => { const nextTime = event.currentTarget.currentTime; setCurrentTime(nextTime); @@ -128,6 +182,10 @@ function TranscriptMediaPlayer({ const setMediaElement = (element: HTMLMediaElement | null): void => { mediaRef.current = element; + if (element) { + applyVolume(element, volume, isMuted); + element.playbackRate = playbackRate; + } }; if (isLoading) { @@ -194,6 +252,40 @@ function TranscriptMediaPlayer({ disabled={safeDuration === 0} /> {formatPlaybackTime(safeDuration)} +
+
+
); diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx b/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx index 69ebf29..3ff5e05 100644 --- a/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx @@ -205,6 +205,44 @@ describe('TranscriptMediaPlayer', () => { pauseSpy.mockRestore(); }); + it('supports volume, mute, and playback speed controls', async () => { + const mediaRef = React.createRef(); + + render( + + ); + + const audio = await screen.findByLabelText('Selected audio preview'); + const volumeSlider = screen.getByLabelText('Volume'); + const speedSelect = screen.getByLabelText('Playback speed'); + + fireEvent.change(volumeSlider, { target: { value: '0.35' } }); + expect(audio).toHaveProperty('volume', 0.35); + expect(audio).toHaveProperty('muted', false); + + fireEvent.click(screen.getByRole('button', { name: 'Mute preview' })); + expect(audio).toHaveProperty('muted', true); + expect(screen.getByRole('button', { name: 'Unmute preview' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Unmute preview' })); + expect(audio).toHaveProperty('muted', false); + + fireEvent.change(volumeSlider, { target: { value: '0' } }); + expect(audio).toHaveProperty('volume', 0); + expect(audio).toHaveProperty('muted', true); + + fireEvent.click(screen.getByRole('button', { name: 'Unmute preview' })); + expect(audio).toHaveProperty('volume', 0.8); + expect(audio).toHaveProperty('muted', false); + + fireEvent.change(speedSelect, { target: { value: '1.5' } }); + expect(audio).toHaveProperty('playbackRate', 1.5); + }); + it('handles play rejections and invalid metadata values', async () => { const mediaRef = React.createRef(); vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('blocked')); From ba14a4b74335e91b929e9011b354189e248813e0 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 7 May 2026 17:05:17 -0300 Subject: [PATCH 04/10] feat: enhance ETA calculation for batch processing in useBatchQueue --- .../__tests__/useBatchQueue.test.ts | 102 ++++++++ .../transcription/hooks/useBatchQueue.ts | 234 ++++++++++++++---- 2 files changed, 288 insertions(+), 48 deletions(-) diff --git a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts index 4fb7b3e..f5b2e98 100644 --- a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts +++ b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts @@ -878,6 +878,108 @@ describe('useBatchQueue', () => { nowSpy.mockRestore(); }); + + it('should scale eta for remaining files by completed item size and duration', async () => { + let now = 1000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + + let resolveSecond: ((value: TranscriptionResult) => void) | undefined; + const startTranscriptionMock = vi + .fn() + .mockImplementationOnce(async () => { + now = 6000; + return { success: true, text: 'first' }; + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }) + ); + + overrideElectronAPI({ + startTranscription: startTranscriptionMock, + onTranscriptionProgress: vi.fn().mockReturnValue(() => {}), + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([ + createMockSelectedFile('short.mp3', { size: 1000 }), + createMockSelectedFile('long.mp3', { size: 2000 }), + ]); + }); + + let processingPromise: Promise; + act(() => { + processingPromise = result.current.startProcessing(); + }); + + await waitFor(() => { + expect(result.current.estimatedTimeRemainingSec).toBe(10); + }); + + await act(async () => { + now = 16000; + resolveSecond?.({ success: true, text: 'second' }); + await processingPromise; + }); + + nowSpy.mockRestore(); + }); + + it('should scale pending eta from current progress when no item has completed yet', async () => { + let now = 1000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + + let progressCb: ((progress: TranscriptionProgress) => void) | undefined; + let resolveTranscription: ((value: TranscriptionResult) => void) | undefined; + + overrideElectronAPI({ + startTranscription: vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveTranscription = resolve; + }) + ), + onTranscriptionProgress: (cb) => { + progressCb = cb; + return () => {}; + }, + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([ + createMockSelectedFile('current.mp3', { size: 1000 }), + createMockSelectedFile('long.mp3', { size: 2000 }), + createMockSelectedFile('short.mp3', { size: 500 }), + ]); + }); + + let processingPromise: Promise; + act(() => { + processingPromise = result.current.startProcessing(); + }); + + act(() => { + now = 2000; + progressCb?.({ percent: 50, status: 'Halfway' }); + }); + + expect(result.current.estimatedTimeRemainingSec).toBe(6); + + await act(async () => { + now = 3000; + resolveTranscription?.({ success: true, text: 'done' }); + await result.current.cancelProcessing(); + await processingPromise; + }); + + nowSpy.mockRestore(); + }); }); describe('cancelProcessing', () => { diff --git a/src/renderer/features/transcription/hooks/useBatchQueue.ts b/src/renderer/features/transcription/hooks/useBatchQueue.ts index d5c491e..87b0bfe 100644 --- a/src/renderer/features/transcription/hooks/useBatchQueue.ts +++ b/src/renderer/features/transcription/hooks/useBatchQueue.ts @@ -68,7 +68,143 @@ interface PersistedQueueItem { error?: string; } +interface EtaSample { + durationMs: number; + fileSize?: number; +} + const QUEUE_STORAGE_KEY = STORAGE_KEYS.QUEUE; +const MIN_PROGRESS_FOR_ETA_PERCENT = 5; + +function getPositiveNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null; +} + +function getAverageDurationMs(samples: EtaSample[]): number | null { + if (samples.length === 0) { + return null; + } + + const totalMs = samples.reduce((total, sample) => total + sample.durationMs, 0); + return totalMs / samples.length; +} + +function getDurationPerByteMs(samples: EtaSample[]): number | null { + const sizedSamples = samples.filter( + (sample) => + getPositiveNumber(sample.durationMs) !== null && getPositiveNumber(sample.fileSize) !== null + ); + + if (sizedSamples.length === 0) { + return null; + } + + const totalMs = sizedSamples.reduce((total, sample) => total + sample.durationMs, 0); + const totalBytes = sizedSamples.reduce((total, sample) => total + (sample.fileSize ?? 0), 0); + + return totalBytes > 0 ? totalMs / totalBytes : null; +} + +function estimateFileDurationMs( + file: SelectedFile, + samples: EtaSample[], + fallbackDurationMs: number | null = null, + fallbackFile: SelectedFile | null = null +): number | null { + const fileSize = getPositiveNumber(file.size); + const durationPerByteMs = getDurationPerByteMs(samples); + + if (fileSize !== null && durationPerByteMs !== null) { + return fileSize * durationPerByteMs; + } + + const fallbackFileSize = getPositiveNumber(fallbackFile?.size); + if (fileSize !== null && fallbackFileSize !== null && fallbackDurationMs !== null) { + return (fallbackDurationMs / fallbackFileSize) * fileSize; + } + + return getAverageDurationMs(samples) ?? fallbackDurationMs; +} + +function estimateCurrentItemDurationMs( + currentItem: QueueItem, + startTimeMs: number, + progressPercent: number, + samples: EtaSample[], + nowMs: number +): number | null { + const elapsedMs = Math.max(0, nowMs - startTimeMs); + const progressRatio = progressPercent / 100; + const progressEstimateMs = + progressPercent >= MIN_PROGRESS_FOR_ETA_PERCENT && progressPercent <= 100 && elapsedMs > 0 + ? elapsedMs / progressRatio + : null; + const sampleEstimateMs = estimateFileDurationMs(currentItem.file, samples); + + if (progressEstimateMs !== null && sampleEstimateMs !== null) { + const progressWeight = Math.min(0.85, Math.max(0.35, progressRatio)); + return progressEstimateMs * progressWeight + sampleEstimateMs * (1 - progressWeight); + } + + return progressEstimateMs ?? sampleEstimateMs; +} + +function calculateEtaMs({ + currentItem, + currentItemStartTimeMs, + progressPercent, + remainingItems, + samples, + nowMs, +}: { + currentItem: QueueItem | null; + currentItemStartTimeMs: number | null; + progressPercent: number; + remainingItems: QueueItem[]; + samples: EtaSample[]; + nowMs: number; +}): number | null { + let currentEstimateMs: number | null = null; + let currentRemainingMs = 0; + + if (currentItem && currentItemStartTimeMs !== null) { + currentEstimateMs = estimateCurrentItemDurationMs( + currentItem, + currentItemStartTimeMs, + progressPercent, + samples, + nowMs + ); + + if (currentEstimateMs !== null) { + currentRemainingMs = Math.max( + 0, + currentEstimateMs - Math.max(0, nowMs - currentItemStartTimeMs) + ); + } + } + + const queuedEstimateMs = remainingItems.reduce((total, item) => { + const estimateMs = estimateFileDurationMs( + item.file, + samples, + currentEstimateMs, + currentItem?.file ?? null + ); + return estimateMs === null ? total : total + estimateMs; + }, 0); + + const totalMs = currentRemainingMs + queuedEstimateMs; + return totalMs > 0 ? totalMs : null; +} + +function toEstimatedSeconds(remainingMs: number | null): number | null { + if (remainingMs === null) { + return null; + } + + return Math.max(1, Math.round(remainingMs / 1000)); +} function isPersistedQueueStatus(status: unknown): status is PersistedQueueStatus { return ( @@ -247,7 +383,9 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const lastPersistedQueueSnapshotRef = useRef(null); const activeRunItemIdsRef = useRef>(new Set()); const currentItemStartTimeRef = useRef(null); - const remainingPendingCountRef = useRef(0); + const currentProcessingItemRef = useRef(null); + const remainingItemsRef = useRef([]); + const etaSamplesRef = useRef([]); const lastProgressPercentRef = useRef(0); const etaIntervalRef = useRef | null>(null); @@ -301,6 +439,23 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur }; }, []); + const updateEstimatedTimeRemaining = useCallback(() => { + if (isCancelledRef.current) { + return; + } + + const remainingMs = calculateEtaMs({ + currentItem: currentProcessingItemRef.current, + currentItemStartTimeMs: currentItemStartTimeRef.current, + progressPercent: lastProgressPercentRef.current, + remainingItems: remainingItemsRef.current, + samples: etaSamplesRef.current, + nowMs: Date.now(), + }); + + setEstimatedTimeRemainingSec(toEstimatedSeconds(remainingMs)); + }, []); + useEffect(() => { if (!isProcessing) { if (etaIntervalRef.current !== null) { @@ -311,22 +466,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur } etaIntervalRef.current = setInterval(() => { - const startTimeMs = currentItemStartTimeRef.current; - const progressPercent = lastProgressPercentRef.current; - - if (isCancelledRef.current || startTimeMs === null || progressPercent <= 0) { - return; - } - - const elapsedMs = Date.now() - startTimeMs; - if (elapsedMs <= 0) return; - - const projectedItemDurationMs = elapsedMs / (progressPercent / 100); - const remainingCurrentMs = Math.max(0, projectedItemDurationMs - elapsedMs); - const remainingMs = - remainingCurrentMs + projectedItemDurationMs * remainingPendingCountRef.current; - - setEstimatedTimeRemainingSec(Math.max(1, Math.round(remainingMs / 1000))); + updateEstimatedTimeRemaining(); }, 1000); return () => { @@ -335,7 +475,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur etaIntervalRef.current = null; } }; - }, [isProcessing]); + }, [isProcessing, updateEstimatedTimeRemaining]); const addFiles = useCallback((files: SelectedFile[]) => { const existingKeys = new Set(queueRef.current.map((item) => getFileIdentityKey(item.file))); @@ -420,6 +560,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur ) ); setCurrentItemId(item.id); + currentProcessingItemRef.current = item; currentItemStartTimeRef.current = startTime; if (progressUnsubscribeRef.current) { @@ -428,6 +569,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur } lastProgressPercentRef.current = 0; + updateEstimatedTimeRemaining(); progressUnsubscribeRef.current = onTranscriptionProgress((progress) => { setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, progress } : q))); @@ -446,16 +588,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur } lastProgressPercentRef.current = progressPercent; - - const elapsedMs = Date.now() - startTimeMs; - if (elapsedMs <= 0) return; - - const projectedItemDurationMs = elapsedMs / (progressPercent / 100); - const remainingCurrentMs = Math.max(0, projectedItemDurationMs - elapsedMs); - const remainingMs = - remainingCurrentMs + projectedItemDurationMs * remainingPendingCountRef.current; - - setEstimatedTimeRemainingSec(Math.max(1, Math.round(remainingMs / 1000))); + updateEstimatedTimeRemaining(); }); logger.info('Processing batch item', { @@ -565,7 +698,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur } } }, - [settings, onHistoryAdd, onFirstComplete] + [settings, onHistoryAdd, onFirstComplete, updateEstimatedTimeRemaining] ); const runProcessing = useCallback( @@ -581,6 +714,11 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const activeIds = new Set(itemsToProcess.map((item) => item.id)); activeRunItemIdsRef.current = activeIds; + etaSamplesRef.current = []; + remainingItemsRef.current = itemsToProcess; + currentProcessingItemRef.current = null; + currentItemStartTimeRef.current = null; + lastProgressPercentRef.current = 0; setEstimatedTimeRemainingSec(null); shouldPersistQueueRef.current = true; @@ -603,7 +741,6 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur logger.info('Starting batch processing', { count: itemsToProcess.length }); - const processedDurationsMs: number[] = []; const processedItems: QueueItem[] = []; for (let index = 0; index < itemsToProcess.length; index++) { @@ -623,34 +760,31 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur } const resetItem = { ...item, status: 'pending' as QueueItemStatus, error: undefined }; - remainingPendingCountRef.current = itemsToProcess.length - (index + 1); + remainingItemsRef.current = itemsToProcess.slice(index + 1); const processedItem = await processItem(resetItem); + currentProcessingItemRef.current = null; currentItemStartTimeRef.current = null; + lastProgressPercentRef.current = 0; processedItems.push(processedItem); shouldPersistQueueRef.current = true; setQueue((prev) => prev.map((q) => (q.id === processedItem.id ? processedItem : q))); if ( + processedItem.status === 'completed' && typeof processedItem.startTime === 'number' && typeof processedItem.endTime === 'number' && processedItem.endTime >= processedItem.startTime ) { - processedDurationsMs.push(processedItem.endTime - processedItem.startTime); + etaSamplesRef.current = [ + ...etaSamplesRef.current, + { + durationMs: processedItem.endTime - processedItem.startTime, + fileSize: getPositiveNumber(processedItem.file.size) ?? undefined, + }, + ]; } - const remainingItemsCount = itemsToProcess.length - (index + 1); - if (remainingItemsCount <= 0) { - setEstimatedTimeRemainingSec(0); - } else if (processedDurationsMs.length > 0) { - const averageDurationMs = - processedDurationsMs.reduce((total, value) => total + value, 0) / - processedDurationsMs.length; - const estimatedSeconds = Math.max( - 1, - Math.round((averageDurationMs * remainingItemsCount) / 1000) - ); - setEstimatedTimeRemainingSec(estimatedSeconds); - } + updateEstimatedTimeRemaining(); } const wasCancelled = isCancelledRef.current; @@ -658,7 +792,9 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur setCurrentItemId(null); activeRunItemIdsRef.current = new Set(); currentItemStartTimeRef.current = null; - remainingPendingCountRef.current = 0; + currentProcessingItemRef.current = null; + remainingItemsRef.current = []; + lastProgressPercentRef.current = 0; setEstimatedTimeRemainingSec(null); if (!wasCancelled) { @@ -667,7 +803,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur logger.info('Batch processing complete'); }, - [isProcessing, queue, processItem] + [isProcessing, queue, processItem, updateEstimatedTimeRemaining] ); const startProcessing = useCallback(async () => { @@ -693,7 +829,9 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur setIsProcessing(false); setCurrentItemId(null); currentItemStartTimeRef.current = null; - remainingPendingCountRef.current = 0; + currentProcessingItemRef.current = null; + remainingItemsRef.current = []; + lastProgressPercentRef.current = 0; setEstimatedTimeRemainingSec(null); shouldPersistQueueRef.current = true; From 2994a0c56e6ddfa8766641ea7a376f3cebdda50b Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 7 May 2026 18:19:47 -0300 Subject: [PATCH 05/10] fix: implement media protocol handling with expiration and eviction logic --- .../utils/__tests__/media-protocol.test.ts | 164 ++++++++++++++++++ src/main/utils/media-protocol.ts | 95 +++++++++- .../OutputDisplay/OutputDisplay.tsx | 6 +- .../TranscriptMediaPlayer.css | 1 + .../TranscriptMediaPlayer.tsx | 9 +- .../__tests__/TranscriptMediaPlayer.test.tsx | 76 +++++--- 6 files changed, 320 insertions(+), 31 deletions(-) create mode 100644 src/main/utils/__tests__/media-protocol.test.ts diff --git a/src/main/utils/__tests__/media-protocol.test.ts b/src/main/utils/__tests__/media-protocol.test.ts new file mode 100644 index 0000000..5b4fa0f --- /dev/null +++ b/src/main/utils/__tests__/media-protocol.test.ts @@ -0,0 +1,164 @@ +import type * as fs from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { handleMock, registerSchemesAsPrivilegedMock, statMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), + statMock: vi.fn(), +})); + +vi.mock('electron', () => ({ + protocol: { + handle: handleMock, + registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, + }, +})); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + + return { + ...actual, + default: { + ...actual, + promises: { + ...actual.promises, + stat: statMock, + }, + }, + promises: { + ...actual.promises, + stat: statMock, + }, + }; +}); + +async function loadMediaProtocolModule() { + vi.resetModules(); + handleMock.mockReset(); + registerSchemesAsPrivilegedMock.mockReset(); + statMock.mockReset(); + + return import('../media-protocol'); +} + +function getRegisteredHandler(): (request: Request) => Promise { + const handler = handleMock.mock.calls[0]?.[1]; + + if (!handler) { + throw new Error('Media protocol handler was not registered'); + } + + return handler as (request: Request) => Promise; +} + +function createProtocolRequest(url: string): Request { + return { + url, + method: 'GET', + headers: new Headers(), + } as Request; +} + +describe('media-protocol', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-07T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('registers the privileged media protocol scheme', async () => { + const { MEDIA_PROTOCOL, registerMediaProtocolScheme } = await loadMediaProtocolModule(); + + registerMediaProtocolScheme(); + + expect(registerSchemesAsPrivilegedMock).toHaveBeenCalledWith([ + expect.objectContaining({ + scheme: MEDIA_PROTOCOL, + privileges: expect.objectContaining({ + secure: true, + standard: true, + stream: true, + supportFetchAPI: true, + }), + }), + ]); + }); + + it('returns 404 and evicts a token when the backing file becomes unavailable', async () => { + const { createMediaProtocolUrl, registerMediaProtocolHandler } = + await loadMediaProtocolModule(); + const url = createMediaProtocolUrl('/tmp/audio.mp3'); + + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + registerMediaProtocolHandler(); + + const handler = getRegisteredHandler(); + const response = await handler(createProtocolRequest(url)); + + expect(response.status).toBe(404); + expect(await response.text()).toBe('Media source not found'); + + statMock.mockClear(); + + const retryResponse = await handler(createProtocolRequest(url)); + + expect(retryResponse.status).toBe(404); + expect(statMock).not.toHaveBeenCalled(); + }); + + it('returns 500 when media loading fails unexpectedly', async () => { + const { createMediaProtocolUrl, registerMediaProtocolHandler } = + await loadMediaProtocolModule(); + const url = createMediaProtocolUrl('/tmp/audio.mp3'); + + statMock.mockRejectedValue(Object.assign(new Error('io failure'), { code: 'EIO' })); + registerMediaProtocolHandler(); + + const response = await getRegisteredHandler()(createProtocolRequest(url)); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('Unable to load media source'); + }); + + it('expires old media tokens before resolving them', async () => { + const { createMediaProtocolUrl, registerMediaProtocolHandler } = + await loadMediaProtocolModule(); + const url = createMediaProtocolUrl('/tmp/audio.mp3'); + + vi.advanceTimersByTime(31 * 60 * 1000); + registerMediaProtocolHandler(); + + const response = await getRegisteredHandler()(createProtocolRequest(url)); + + expect(response.status).toBe(404); + expect(statMock).not.toHaveBeenCalled(); + }); + + it('evicts the least recently used token once the cache reaches capacity', async () => { + const { createMediaProtocolUrl, registerMediaProtocolHandler } = + await loadMediaProtocolModule(); + const urls = Array.from({ length: 101 }, (_, index) => + createMediaProtocolUrl(`/tmp/audio-${index}.mp3`) + ); + const oldestUrl = urls[0]!; + const newestUrl = urls[100]!; + + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + registerMediaProtocolHandler(); + + const handler = getRegisteredHandler(); + const oldestResponse = await handler(createProtocolRequest(oldestUrl)); + + expect(oldestResponse.status).toBe(404); + expect(statMock).not.toHaveBeenCalled(); + + const newestResponse = await handler(createProtocolRequest(newestUrl)); + + expect(newestResponse.status).toBe(404); + expect(statMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/utils/media-protocol.ts b/src/main/utils/media-protocol.ts index b886150..0a80904 100644 --- a/src/main/utils/media-protocol.ts +++ b/src/main/utils/media-protocol.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import { protocol } from 'electron'; import fs from 'fs'; import path from 'path'; @@ -5,7 +6,15 @@ import { Readable } from 'stream'; export const MEDIA_PROTOCOL = 'whisperdesk-media'; -const mediaSources = new Map(); +const MEDIA_SOURCE_TTL_MS = 30 * 60 * 1000; +const MAX_MEDIA_SOURCES = 100; + +interface MediaSourceEntry { + filePath: string; + expiresAt: number; +} + +const mediaSources = new Map(); let protocolHandlerRegistered = false; interface ByteRange { @@ -13,6 +22,69 @@ interface ByteRange { end: number; } +function getMediaSourceExpiration(now = Date.now()): number { + return now + MEDIA_SOURCE_TTL_MS; +} + +function purgeExpiredMediaSources(now = Date.now()): void { + for (const [token, entry] of mediaSources) { + if (entry.expiresAt > now) { + continue; + } + + mediaSources.delete(token); + } +} + +function evictLeastRecentlyUsedMediaSources(): void { + while (mediaSources.size >= MAX_MEDIA_SOURCES) { + const oldestToken = mediaSources.keys().next().value; + + if (!oldestToken) { + break; + } + + mediaSources.delete(oldestToken); + } +} + +function getMediaSourceEntry(token: string): MediaSourceEntry | null { + const entry = mediaSources.get(token); + + if (!entry) { + return null; + } + + if (entry.expiresAt <= Date.now()) { + mediaSources.delete(token); + return null; + } + + const refreshedEntry: MediaSourceEntry = { + filePath: entry.filePath, + expiresAt: getMediaSourceExpiration(), + }; + mediaSources.delete(token); + mediaSources.set(token, refreshedEntry); + return refreshedEntry; +} + +function createMediaErrorResponse(error: unknown): Response { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : null; + + if ( + errorCode === 'ENOENT' || + errorCode === 'ENOTDIR' || + errorCode === 'EACCES' || + errorCode === 'EPERM' + ) { + return new Response('Media source not found', { status: 404 }); + } + + return new Response('Unable to load media source', { status: 500 }); +} + function getContentType(filePath: string): string { const extension = path.extname(filePath).toLowerCase(); const contentTypes: Record = { @@ -145,21 +217,32 @@ export function registerMediaProtocolHandler(): void { protocol.handle(MEDIA_PROTOCOL, async (request) => { const url = new URL(request.url); const token = url.hostname || url.pathname.replace(/^\//, ''); - const filePath = mediaSources.get(token); + const entry = getMediaSourceEntry(token); - if (!filePath) { + if (!entry) { return new Response('Media source not found', { status: 404 }); } - return createMediaResponse(request, filePath); + try { + return await createMediaResponse(request, entry.filePath); + } catch (error) { + mediaSources.delete(token); + return createMediaErrorResponse(error); + } }); protocolHandlerRegistered = true; } export function createMediaProtocolUrl(filePath: string): string { + purgeExpiredMediaSources(); + evictLeastRecentlyUsedMediaSources(); + const resolvedPath = path.resolve(filePath); - const token = crypto.randomUUID(); - mediaSources.set(token, resolvedPath); + const token = randomUUID(); + mediaSources.set(token, { + filePath: resolvedPath, + expiresAt: getMediaSourceExpiration(), + }); return `${MEDIA_PROTOCOL}://${token}`; } diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx index f569de9..b67ea4a 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx @@ -161,6 +161,10 @@ function OutputDisplay({ void media.play().catch(() => {}); }, []); + const handleMediaElementChange = useCallback((element: HTMLMediaElement | null): void => { + mediaRef.current = element; + }, []); + return (
)} diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css index ba53aba..cb038ae 100644 --- a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css @@ -14,6 +14,7 @@ } .transcript-media-unavailable { + display: flex; flex-direction: row; align-items: center; gap: 8px; diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx index 37c330d..1570aee 100644 --- a/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, type RefObject } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { AlertCircle, Pause, Play, Volume2, VolumeX } from 'lucide-react'; import { Button } from '../../../../components/ui'; import { getMediaSource } from '../../../../services/electronAPI'; @@ -7,7 +7,7 @@ import './TranscriptMediaPlayer.css'; export interface TranscriptMediaPlayerProps { selectedFile: SelectedFile | null; - mediaRef: RefObject; + onMediaElementChange?: (element: HTMLMediaElement | null) => void; onPlaybackTimeChange: (timeSec: number) => void; } @@ -36,9 +36,10 @@ function formatPlaybackTime(value: number): string { function TranscriptMediaPlayer({ selectedFile, - mediaRef, + onMediaElementChange, onPlaybackTimeChange, }: TranscriptMediaPlayerProps): React.JSX.Element | null { + const mediaRef = useRef(null); const [source, setSource] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isPlaying, setIsPlaying] = useState(false); @@ -182,6 +183,8 @@ function TranscriptMediaPlayer({ const setMediaElement = (element: HTMLMediaElement | null): void => { mediaRef.current = element; + onMediaElementChange?.(element); + if (element) { applyVolume(element, volume, isMuted); element.playbackRate = playbackRate; diff --git a/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx b/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx index 3ff5e05..122c119 100644 --- a/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx @@ -1,10 +1,23 @@ -import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TranscriptMediaPlayer } from '../TranscriptMediaPlayer'; import { createFullElectronAPIMock } from '@/test/electronAPIMocks'; import type { ElectronAPI } from '@/types/electron'; +function createMediaElementChangeHandler(): { + mediaElement: { current: HTMLMediaElement | null }; + onMediaElementChange: (element: HTMLMediaElement | null) => void; +} { + const mediaElement = { current: null as HTMLMediaElement | null }; + + return { + mediaElement, + onMediaElementChange: (element) => { + mediaElement.current = element; + }, + }; +} + describe('TranscriptMediaPlayer', () => { beforeEach(() => { vi.clearAllMocks(); @@ -12,13 +25,13 @@ describe('TranscriptMediaPlayer', () => { }); it('renders nothing without a selected file', () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); const onPlaybackTimeChange = vi.fn(); const { container } = render( ); @@ -28,7 +41,7 @@ describe('TranscriptMediaPlayer', () => { }); it('shows a loading state while resolving the media source', async () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); const onPlaybackTimeChange = vi.fn(); let resolveSource: (value: { success: boolean; url: string; mediaType: 'audio' }) => void; window.electronAPI = { @@ -44,7 +57,7 @@ describe('TranscriptMediaPlayer', () => { render( ); @@ -59,7 +72,7 @@ describe('TranscriptMediaPlayer', () => { }); it('renders unavailable state for missing media sources', async () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); window.electronAPI = { ...createFullElectronAPIMock(), getMediaSource: vi.fn().mockResolvedValue({ success: false }), @@ -68,7 +81,7 @@ describe('TranscriptMediaPlayer', () => { render( ); @@ -77,12 +90,12 @@ describe('TranscriptMediaPlayer', () => { }); it('handles files without a usable path', async () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); render( ); @@ -91,7 +104,7 @@ describe('TranscriptMediaPlayer', () => { }); it('shows media source errors when source resolution rejects', async () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); window.electronAPI = { ...createFullElectronAPIMock(), getMediaSource: vi.fn().mockRejectedValue(new Error('Preview blocked')), @@ -100,7 +113,7 @@ describe('TranscriptMediaPlayer', () => { render( ); @@ -109,7 +122,7 @@ describe('TranscriptMediaPlayer', () => { }); it('stringifies non-error media source rejections', async () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); window.electronAPI = { ...createFullElectronAPIMock(), getMediaSource: vi.fn().mockRejectedValue('Preview unavailable'), @@ -118,7 +131,7 @@ describe('TranscriptMediaPlayer', () => { render( ); @@ -127,7 +140,7 @@ describe('TranscriptMediaPlayer', () => { }); it('renders video preview and updates duration from metadata', async () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); window.electronAPI = { ...createFullElectronAPIMock(), getMediaSource: vi.fn().mockResolvedValue({ @@ -140,7 +153,7 @@ describe('TranscriptMediaPlayer', () => { render( ); @@ -153,7 +166,7 @@ describe('TranscriptMediaPlayer', () => { }); it('supports play, pause, seeking, time updates, and ended state', async () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); const onPlaybackTimeChange = vi.fn(); const playSpy = vi .spyOn(window.HTMLMediaElement.prototype, 'play') @@ -165,7 +178,7 @@ describe('TranscriptMediaPlayer', () => { render( ); @@ -206,17 +219,19 @@ describe('TranscriptMediaPlayer', () => { }); it('supports volume, mute, and playback speed controls', async () => { - const mediaRef = React.createRef(); + const { mediaElement, onMediaElementChange } = createMediaElementChangeHandler(); render( ); const audio = await screen.findByLabelText('Selected audio preview'); + expect(mediaElement.current).toBe(audio); + const volumeSlider = screen.getByLabelText('Volume'); const speedSelect = screen.getByLabelText('Playback speed'); @@ -244,13 +259,13 @@ describe('TranscriptMediaPlayer', () => { }); it('handles play rejections and invalid metadata values', async () => { - const mediaRef = React.createRef(); + const { onMediaElementChange } = createMediaElementChangeHandler(); vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('blocked')); render( ); @@ -264,4 +279,23 @@ describe('TranscriptMediaPlayer', () => { expect(screen.getByRole('button', { name: 'Play preview' })).toBeInTheDocument(); }); }); + + it('clears the external media element when the player unmounts', async () => { + const { mediaElement, onMediaElementChange } = createMediaElementChangeHandler(); + + const { unmount } = render( + + ); + + await screen.findByLabelText('Selected audio preview'); + expect(mediaElement.current).toBeInstanceOf(window.HTMLAudioElement); + + unmount(); + + expect(mediaElement.current).toBeNull(); + }); }); From 1a7e8449bd9405f6d79686e97b82fc9d461cbdc1 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 7 May 2026 18:29:08 -0300 Subject: [PATCH 06/10] fix: enhance media protocol tests with range request handling and error responses --- .../utils/__tests__/media-protocol.test.ts | 148 +++++++++++++++++- 1 file changed, 140 insertions(+), 8 deletions(-) diff --git a/src/main/utils/__tests__/media-protocol.test.ts b/src/main/utils/__tests__/media-protocol.test.ts index 5b4fa0f..254901f 100644 --- a/src/main/utils/__tests__/media-protocol.test.ts +++ b/src/main/utils/__tests__/media-protocol.test.ts @@ -1,11 +1,16 @@ import type * as fs from 'fs'; +import { Readable } from 'stream'; +import type { ReadableStream as NodeReadableStream } from 'stream/web'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { handleMock, registerSchemesAsPrivilegedMock, statMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - registerSchemesAsPrivilegedMock: vi.fn(), - statMock: vi.fn(), -})); +const { createReadStreamMock, handleMock, registerSchemesAsPrivilegedMock, statMock } = vi.hoisted( + () => ({ + createReadStreamMock: vi.fn(), + handleMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), + statMock: vi.fn(), + }) +); vi.mock('electron', () => ({ protocol: { @@ -21,11 +26,13 @@ vi.mock('fs', async () => { ...actual, default: { ...actual, + createReadStream: createReadStreamMock, promises: { ...actual.promises, stat: statMock, }, }, + createReadStream: createReadStreamMock, promises: { ...actual.promises, stat: statMock, @@ -35,6 +42,7 @@ vi.mock('fs', async () => { async function loadMediaProtocolModule() { vi.resetModules(); + createReadStreamMock.mockReset(); handleMock.mockReset(); registerSchemesAsPrivilegedMock.mockReset(); statMock.mockReset(); @@ -52,11 +60,20 @@ function getRegisteredHandler(): (request: Request) => Promise { return handler as (request: Request) => Promise; } -function createProtocolRequest(url: string): Request { +function createProtocolRequest( + url: string, + options?: { method?: 'GET' | 'HEAD'; range?: string } +): Request { + const headers = new Headers(); + + if (options?.range) { + headers.set('range', options.range); + } + return { url, - method: 'GET', - headers: new Headers(), + method: options?.method ?? 'GET', + headers, } as Request; } @@ -68,6 +85,7 @@ describe('media-protocol', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); it('registers the privileged media protocol scheme', async () => { @@ -124,6 +142,47 @@ describe('media-protocol', () => { expect(await response.text()).toBe('Unable to load media source'); }); + it.each(['ENOTDIR', 'EACCES', 'EPERM'])('returns 404 for %s file access errors', async (code) => { + const { createMediaProtocolUrl, registerMediaProtocolHandler } = + await loadMediaProtocolModule(); + const url = createMediaProtocolUrl('/tmp/audio.mp3'); + + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code })); + registerMediaProtocolHandler(); + + const response = await getRegisteredHandler()(createProtocolRequest(url)); + + expect(response.status).toBe(404); + expect(await response.text()).toBe('Media source not found'); + }); + + it('registers the handler once and resolves tokens from the pathname when needed', async () => { + const { createMediaProtocolUrl, registerMediaProtocolHandler, MEDIA_PROTOCOL } = + await loadMediaProtocolModule(); + const url = createMediaProtocolUrl('/tmp/audio.bin'); + const token = new URL(url).hostname; + const pathnameUrl = `${MEDIA_PROTOCOL}:///${token}`; + const webStream = new ReadableStream() as unknown as NodeReadableStream; + + statMock.mockResolvedValue({ size: 12 }); + createReadStreamMock.mockReturnValue({} as fs.ReadStream); + vi.spyOn(Readable, 'toWeb').mockReturnValue(webStream); + + registerMediaProtocolHandler(); + registerMediaProtocolHandler(); + + expect(handleMock).toHaveBeenCalledTimes(1); + + const response = await getRegisteredHandler()(createProtocolRequest(pathnameUrl)); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('application/octet-stream'); + expect(response.headers.get('Content-Length')).toBe('12'); + expect(response.headers.get('Content-Range')).toBeNull(); + expect(createReadStreamMock).toHaveBeenCalledWith('/tmp/audio.bin', { start: 0, end: 11 }); + expect(Readable.toWeb).toHaveBeenCalledOnce(); + }); + it('expires old media tokens before resolving them', async () => { const { createMediaProtocolUrl, registerMediaProtocolHandler } = await loadMediaProtocolModule(); @@ -161,4 +220,77 @@ describe('media-protocol', () => { expect(newestResponse.status).toBe(404); expect(statMock).toHaveBeenCalledTimes(1); }); + + it('supports valid range requests and head requests', async () => { + const { createMediaProtocolUrl, registerMediaProtocolHandler } = + await loadMediaProtocolModule(); + const url = createMediaProtocolUrl('/tmp/audio.mp4'); + const webStream = new ReadableStream() as unknown as NodeReadableStream; + + statMock.mockResolvedValue({ size: 10 }); + createReadStreamMock.mockReturnValue({} as fs.ReadStream); + vi.spyOn(Readable, 'toWeb').mockReturnValue(webStream); + registerMediaProtocolHandler(); + + const handler = getRegisteredHandler(); + const explicitRangeResponse = await handler(createProtocolRequest(url, { range: 'bytes=2-4' })); + + expect(explicitRangeResponse.status).toBe(206); + expect(explicitRangeResponse.headers.get('Content-Type')).toBe('video/mp4'); + expect(explicitRangeResponse.headers.get('Content-Length')).toBe('3'); + expect(explicitRangeResponse.headers.get('Content-Range')).toBe('bytes 2-4/10'); + + const openEndedRangeResponse = await handler(createProtocolRequest(url, { range: 'bytes=5-' })); + + expect(openEndedRangeResponse.status).toBe(206); + expect(openEndedRangeResponse.headers.get('Content-Length')).toBe('5'); + expect(openEndedRangeResponse.headers.get('Content-Range')).toBe('bytes 5-9/10'); + + const suffixRangeResponse = await handler(createProtocolRequest(url, { range: 'bytes=-3' })); + + expect(suffixRangeResponse.status).toBe(206); + expect(suffixRangeResponse.headers.get('Content-Length')).toBe('3'); + expect(suffixRangeResponse.headers.get('Content-Range')).toBe('bytes 7-9/10'); + + statMock.mockResolvedValue({ size: 0 }); + + const headResponse = await handler(createProtocolRequest(url, { method: 'HEAD' })); + + expect(headResponse.status).toBe(200); + expect(headResponse.headers.get('Content-Length')).toBe('0'); + expect(createReadStreamMock).toHaveBeenNthCalledWith(1, '/tmp/audio.mp4', { start: 2, end: 4 }); + expect(createReadStreamMock).toHaveBeenNthCalledWith(2, '/tmp/audio.mp4', { start: 5, end: 9 }); + expect(createReadStreamMock).toHaveBeenNthCalledWith(3, '/tmp/audio.mp4', { start: 7, end: 9 }); + expect(createReadStreamMock).toHaveBeenCalledTimes(3); + }); + + it('returns 416 for invalid range requests', async () => { + const { createMediaProtocolUrl, registerMediaProtocolHandler } = + await loadMediaProtocolModule(); + const url = createMediaProtocolUrl('/tmp/audio.mp3'); + + statMock.mockResolvedValue({ size: 10 }); + registerMediaProtocolHandler(); + + const handler = getRegisteredHandler(); + const missingRangeBoundsResponse = await handler( + createProtocolRequest(url, { range: 'bytes=-' }) + ); + + expect(missingRangeBoundsResponse.status).toBe(416); + expect(missingRangeBoundsResponse.headers.get('Content-Range')).toBe('bytes */10'); + + const invalidSuffixRangeResponse = await handler( + createProtocolRequest(url, { range: 'bytes=-0' }) + ); + + expect(invalidSuffixRangeResponse.status).toBe(416); + + const outOfBoundsRangeResponse = await handler( + createProtocolRequest(url, { range: 'bytes=10-12' }) + ); + + expect(outOfBoundsRangeResponse.status).toBe(416); + expect(createReadStreamMock).not.toHaveBeenCalled(); + }); }); From 8da5d0dbcd2355682a7fe7fcfab7bc1668dee605 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 7 May 2026 21:52:58 -0300 Subject: [PATCH 07/10] refactor: reorganize media extension types and improve media file validation logic --- src/main/ipc/index.ts | 37 +++++++++++++++---- .../OutputDisplay/OutputDisplay.tsx | 30 ++++++++++++--- .../__tests__/OutputDisplay.test.tsx | 22 +++++++++++ src/shared/types.ts | 16 +++----- 4 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index dd687a0..c1ff9bd 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -20,16 +20,16 @@ import { generateFileFingerprint } from '../utils/media-info'; import { createMediaProtocolUrl } from '../utils/media-protocol'; import { safeSend } from '../utils/safe-send'; import { trackEvent, AnalyticsEvents } from '../services/analytics'; -import { SUPPORTED_EXTENSIONS } from '../../shared/types'; -import type { TranscriptionOptions, SaveFileOptions } from '../../shared/types'; +import { SUPPORTED_EXTENSIONS, VIDEO_EXTENSIONS } from '../../shared/types'; +import type { TranscriptionOptions, SaveFileOptions, SupportedExtension } from '../../shared/types'; const OPEN_DIALOG_MEDIA_EXTENSIONS = [...SUPPORTED_EXTENSIONS]; -const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', 'flv', 'm4v']); +const VIDEO_EXTENSION_SET = new Set(VIDEO_EXTENSIONS); -function getSupportedMediaExtension(filePath: string): string | null { +function getSupportedMediaExtension(filePath: string): SupportedExtension | null { const extension = path.extname(filePath).replace('.', '').toLowerCase(); - return SUPPORTED_EXTENSIONS.includes(extension as (typeof SUPPORTED_EXTENSIONS)[number]) - ? extension + return SUPPORTED_EXTENSIONS.includes(extension as SupportedExtension) + ? (extension as SupportedExtension) : null; } @@ -134,7 +134,28 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { return { success: false, error: 'Invalid file path' }; } - const resolvedPath = path.resolve(filePath); + const normalizedPath = path.resolve(filePath); + let resolvedPath: string; + + try { + const linkStats = await fs.promises.lstat(normalizedPath); + if (linkStats.isSymbolicLink()) { + return { success: false, error: 'Unsupported media file' }; + } + + if (!linkStats.isFile()) { + return { success: false, error: 'Unsupported media file' }; + } + + resolvedPath = await fs.promises.realpath(normalizedPath); + const stats = await fs.promises.stat(resolvedPath); + if (!stats.isFile()) { + return { success: false, error: 'Unsupported media file' }; + } + } catch { + return { success: false, error: 'File not found' }; + } + const extension = getSupportedMediaExtension(resolvedPath); if (!extension) { return { success: false, error: 'Unsupported media file' }; @@ -149,7 +170,7 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { return { success: true, url: createMediaProtocolUrl(resolvedPath), - mediaType: VIDEO_EXTENSIONS.has(extension) ? 'video' : 'audio', + mediaType: VIDEO_EXTENSION_SET.has(extension) ? 'video' : 'audio', }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx index b67ea4a..3505ac2 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx @@ -35,11 +35,13 @@ function OutputDisplay({ const mediaRef = useRef(null); const hasText = text.length > 0; - const wordCount = hasText ? text.trim().split(/\s+/).length : 0; - const charCount = hasText ? text.length : 0; const segments = useMemo(() => parseTranscriptSegments(text), [text]); const hasSegments = segments.length > 0; const searchableText = hasSegments ? segments.map((segment) => segment.text).join('\n') : text; + const statText = hasSegments ? searchableText : text; + const trimmedStatText = statText.trim(); + const wordCount = trimmedStatText ? trimmedStatText.split(/\s+/).length : 0; + const charCount = statText.length; const matches = useMemo((): SearchMatch[] => { if (!searchQuery || !searchableText) return []; @@ -71,10 +73,26 @@ function OutputDisplay({ return null; } - const activeSegment = segments.find( - (segment) => playbackTime >= segment.startSec && playbackTime < segment.endSec - ); - return activeSegment?.index ?? null; + let low = 0; + let high = segments.length - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const segment = segments[mid]; + if (!segment) { + return null; + } + + if (playbackTime < segment.startSec) { + high = mid - 1; + } else if (playbackTime >= segment.endSec) { + low = mid + 1; + } else { + return segment.index; + } + } + + return null; }, [hasSegments, playbackTime, segments]); useEffect(() => { diff --git a/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx b/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx index 4da6c83..6498e18 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx @@ -55,6 +55,28 @@ describe('OutputDisplay', () => { expect(wordCountElement).toBeInTheDocument(); }); + it('should count transcript segment text instead of VTT metadata', () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + + render( + 00:00:03.000 +First segment + +00:00:04.000 --> 00:00:06.000 +Second segment`} + onSave={onSave} + onCopy={onCopy} + copySuccess={false} + /> + ); + + expect(screen.getByText('4 words · 28 chars')).toBeInTheDocument(); + }); + it('should call onCopy when copy button is clicked', async () => { const onSave = vi.fn(); const onCopy = vi.fn(); diff --git a/src/shared/types.ts b/src/shared/types.ts index 6e675e1..22d48ac 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -181,8 +181,7 @@ export const OUTPUT_FORMATS: readonly OutputFormatOption[] = [ { value: 'txt', label: 'Plain Text', ext: '.txt' }, ] as const; -export const SUPPORTED_EXTENSIONS = [ - // Audio +export const AUDIO_EXTENSIONS = [ 'mp3', 'wav', 'm4a', @@ -194,17 +193,12 @@ export const SUPPORTED_EXTENSIONS = [ 'wma', 'aac', 'aiff', - // Video - 'mp4', - 'mov', - 'avi', - 'mkv', - 'webm', - 'wmv', - 'flv', - 'm4v', ] as const; +export const VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', 'flv', 'm4v'] as const; + +export const SUPPORTED_EXTENSIONS = [...AUDIO_EXTENSIONS, ...VIDEO_EXTENSIONS] as const; + export type SupportedExtension = (typeof SUPPORTED_EXTENSIONS)[number]; export type Unsubscribe = () => void; From ec8d48906a5a6d9f933629b980dcea8c55b014ab Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 7 May 2026 22:10:43 -0300 Subject: [PATCH 08/10] feat: add media player toggle functionality and update related UI components --- .../OutputDisplay/OutputDisplay.tsx | 35 ++++++--- .../__tests__/OutputDisplay.test.tsx | 72 ++++++++++++++++++- .../TranscriptionToolbar.css | 57 +++++++++++++++ .../TranscriptionToolbar.tsx | 21 ++++++ 4 files changed, 175 insertions(+), 10 deletions(-) diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx index 3505ac2..1e584a5 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx @@ -32,13 +32,18 @@ function OutputDisplay({ const [searchQuery, setSearchQuery] = useState(''); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [playbackTime, setPlaybackTime] = useState(0); + const [isMediaPlayerEnabled, setIsMediaPlayerEnabled] = useState(true); const mediaRef = useRef(null); const hasText = text.length > 0; const segments = useMemo(() => parseTranscriptSegments(text), [text]); const hasSegments = segments.length > 0; - const searchableText = hasSegments ? segments.map((segment) => segment.text).join('\n') : text; - const statText = hasSegments ? searchableText : text; + const canUseMediaMode = hasText && hasSegments && Boolean(selectedFile); + const isMediaModeEnabled = canUseMediaMode && isMediaPlayerEnabled; + const searchableText = isMediaModeEnabled + ? segments.map((segment) => segment.text).join('\n') + : text; + const statText = isMediaModeEnabled ? searchableText : text; const trimmedStatText = statText.trim(); const wordCount = trimmedStatText ? trimmedStatText.split(/\s+/).length : 0; const charCount = statText.length; @@ -69,7 +74,7 @@ function OutputDisplay({ }, [currentMatchIndex, matches.length]); const activeSegmentIndex = useMemo((): number | null => { - if (!hasSegments) { + if (!isMediaModeEnabled) { return null; } @@ -93,7 +98,7 @@ function OutputDisplay({ } return null; - }, [hasSegments, playbackTime, segments]); + }, [isMediaModeEnabled, playbackTime, segments]); useEffect(() => { const handleKeyDown = (e: globalThis.KeyboardEvent): void => { @@ -126,6 +131,10 @@ function OutputDisplay({ } }; + const handleToggleMediaPlayer = (enabled: boolean): void => { + setIsMediaPlayerEnabled(enabled); + }; + const handleCloseSearch = (): void => { setShowSearch(false); setSearchQuery(''); @@ -140,7 +149,7 @@ function OutputDisplay({ }; const highlightedText = useMemo((): React.JSX.Element[] | null => { - if (hasSegments || !searchQuery || !text || matches.length === 0) return null; + if (isMediaModeEnabled || !searchQuery || !text || matches.length === 0) return null; const parts: React.JSX.Element[] = []; let lastIndex = 0; @@ -166,7 +175,7 @@ function OutputDisplay({ } return parts; - }, [hasSegments, text, searchQuery, matches, currentMatchIndex]); + }, [isMediaModeEnabled, text, searchQuery, matches, currentMatchIndex]); const handleSegmentClick = useCallback((segment: TranscriptSegment): void => { const media = mediaRef.current; @@ -183,6 +192,13 @@ function OutputDisplay({ mediaRef.current = element; }, []); + useEffect(() => { + if (!isMediaModeEnabled) { + mediaRef.current = null; + setPlaybackTime(0); + } + }, [isMediaModeEnabled]); + return (
{showSearch && hasText && ( @@ -208,7 +227,7 @@ function OutputDisplay({ /> )} - {hasText && hasSegments && selectedFile && ( + {isMediaModeEnabled && selectedFile && ( { expect(wordCountElement).toBeInTheDocument(); }); - it('should count transcript segment text instead of VTT metadata', () => { + it('should count transcript segment text instead of VTT metadata in media mode', async () => { const onSave = vi.fn(); const onCopy = vi.fn(); + window.electronAPI = createFullElectronAPIMock(); render( 00:00:06.000 Second segment`} + selectedFile={{ name: 'file.mp3', path: '/path/file.mp3' }} onSave={onSave} onCopy={onCopy} copySuccess={false} @@ -75,6 +77,7 @@ Second segment`} ); expect(screen.getByText('4 words · 28 chars')).toBeInTheDocument(); + expect(await screen.findByLabelText('Selected audio preview')).toBeInTheDocument(); }); it('should call onCopy when copy button is clicked', async () => { @@ -832,7 +835,7 @@ Second segment`} expect(searchInput).toBeInTheDocument(); }); - it('should render timestamped segments when VTT is present', () => { + it('should render VTT as plain text when media mode is unavailable', () => { const onSave = vi.fn(); const onCopy = vi.fn(); @@ -840,11 +843,34 @@ Second segment`} 00:00:03.000 +First segment`} + onSave={onSave} + onCopy={onCopy} + copySuccess={false} + /> + ); + + expect(screen.queryByLabelText('Timestamped transcript')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Transcribed text')).toHaveTextContent('WEBVTT'); + expect(screen.queryByRole('switch', { name: /media player/i })).not.toBeInTheDocument(); + }); + + it('should render timestamped segments when media mode is available', async () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + window.electronAPI = createFullElectronAPIMock(); + + render( + 00:00:03.000 First segment 00:00:04.000 --> 00:00:06.000 Second segment`} + selectedFile={{ name: 'file.mp3', path: '/path/file.mp3' }} onSave={onSave} onCopy={onCopy} copySuccess={false} @@ -854,6 +880,7 @@ Second segment`} expect(screen.getByLabelText('Timestamped transcript')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Play from 00:00:01.000/i })).toBeInTheDocument(); expect(screen.getByText('Second segment')).toBeInTheDocument(); + expect(await screen.findByLabelText('Selected audio preview')).toBeInTheDocument(); }); it('should seek and play media when a timestamp is clicked', async () => { @@ -888,6 +915,45 @@ First segment`} playSpy.mockRestore(); }); + it('should toggle the media player while keeping the transcript visible', async () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + window.electronAPI = createFullElectronAPIMock(); + + render( + 00:00:03.000 +First segment`} + selectedFile={{ name: 'file.mp3', path: '/path/file.mp3' }} + onSave={onSave} + onCopy={onCopy} + copySuccess={false} + /> + ); + + expect(await screen.findByLabelText('Selected audio preview')).toBeInTheDocument(); + + const mediaSwitch = screen.getByRole('switch', { name: /media player/i }); + expect(mediaSwitch).toBeChecked(); + + fireEvent.click(mediaSwitch); + + await waitFor(() => { + expect(screen.queryByLabelText('Selected audio preview')).not.toBeInTheDocument(); + }); + expect(screen.queryByLabelText('Timestamped transcript')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Transcribed text')).toHaveTextContent('WEBVTT'); + expect(screen.getByLabelText('Transcribed text')).toHaveTextContent('First segment'); + expect(mediaSwitch).not.toBeChecked(); + + fireEvent.click(mediaSwitch); + + expect(await screen.findByLabelText('Selected audio preview')).toBeInTheDocument(); + expect(screen.getByLabelText('Timestamped transcript')).toBeInTheDocument(); + }); + it('should highlight the active segment from media time updates', async () => { const onSave = vi.fn(); const onCopy = vi.fn(); @@ -947,6 +1013,7 @@ First segment`} it('should search and highlight matches in timestamped segments', async () => { const onSave = vi.fn(); const onCopy = vi.fn(); + window.electronAPI = createFullElectronAPIMock(); render( 00:00:06.000 beta gamma`} + selectedFile={{ name: 'file.mp3', path: '/path/file.mp3' }} onSave={onSave} onCopy={onCopy} copySuccess={false} diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css index 642a441..5617216 100644 --- a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css @@ -27,9 +27,66 @@ .output-actions { display: flex; + align-items: center; gap: 8px; } +.media-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; +} + +.media-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.media-toggle-track { + position: relative; + width: 34px; + height: 20px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + transition: + background var(--transition-fast), + border-color var(--transition-fast); +} + +.media-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--text-muted); + transition: + transform var(--transition-fast), + background var(--transition-fast); +} + +.media-toggle input:checked + .media-toggle-track { + background: var(--accent-light); + border-color: var(--accent-border); +} + +.media-toggle input:checked + .media-toggle-track .media-toggle-thumb { + transform: translateX(14px); + background: var(--accent); +} + +.media-toggle input:focus-visible + .media-toggle-track { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .save-dropdown { position: relative; } diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx index 8ad130a..2d01847 100644 --- a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx @@ -14,6 +14,9 @@ export interface TranscriptionToolbarProps { charCount: number; onToggleSearch: () => void; isSearchActive: boolean; + showMediaToggle?: boolean; + isMediaPlayerEnabled?: boolean; + onToggleMediaPlayer?: (enabled: boolean) => void; } function TranscriptionToolbar({ @@ -25,6 +28,9 @@ function TranscriptionToolbar({ charCount, onToggleSearch, isSearchActive, + showMediaToggle = false, + isMediaPlayerEnabled = true, + onToggleMediaPlayer, }: TranscriptionToolbarProps): React.JSX.Element { const [showSaveMenu, setShowSaveMenu] = useState(false); const saveMenuRef = useRef(null); @@ -59,6 +65,21 @@ function TranscriptionToolbar({
{hasText && (
+ {showMediaToggle && ( + + )}