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..80932b3 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -17,12 +17,18 @@ import { generateMarkdownDocument, } from '../utils/export-helper'; import { generateFileFingerprint } from '../utils/media-info'; +import { createMediaProtocolUrl } from '../utils/media-protocol'; +import { + approveMediaFilePaths, + resolveApprovedMediaFilePath, +} from '../utils/media-source-authorization'; import { safeSend } from '../utils/safe-send'; import { trackEvent, AnalyticsEvents } from '../services/analytics'; -import { SUPPORTED_EXTENSIONS } from '../../shared/types'; +import { SUPPORTED_EXTENSIONS, VIDEO_EXTENSIONS } from '../../shared/types'; import type { TranscriptionOptions, SaveFileOptions } from '../../shared/types'; const OPEN_DIALOG_MEDIA_EXTENSIONS = [...SUPPORTED_EXTENSIONS]; +const VIDEO_EXTENSION_SET = new Set(VIDEO_EXTENSIONS); export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { ipcMain.handle('dialog:openFile', async () => { @@ -41,6 +47,7 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { if (canceled) { return null; } + await approveMediaFilePaths(filePaths); return filePaths[0]; }); @@ -57,7 +64,12 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { }, ], }); - return canceled ? null : filePaths; + if (canceled) { + return null; + } + + await approveMediaFilePaths(filePaths); + return filePaths; }); ipcMain.handle('dialog:saveFile', async (_event, options: SaveFileOptions) => { @@ -115,6 +127,23 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { } }); + ipcMain.handle('file:getMediaSource', async (_event, filePath: string) => { + try { + const validation = await resolveApprovedMediaFilePath(filePath); + if (!validation.success) { + return { success: false, error: validation.error }; + } + + return { + success: true, + url: createMediaProtocolUrl(validation.filePath), + mediaType: VIDEO_EXTENSION_SET.has(validation.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/__tests__/media-protocol.test.ts b/src/main/utils/__tests__/media-protocol.test.ts new file mode 100644 index 0000000..3e76ebb --- /dev/null +++ b/src/main/utils/__tests__/media-protocol.test.ts @@ -0,0 +1,307 @@ +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 { createReadStreamMock, handleMock, registerSchemesAsPrivilegedMock, statMock } = vi.hoisted( + () => ({ + createReadStreamMock: vi.fn(), + 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, + createReadStream: createReadStreamMock, + promises: { + ...actual.promises, + stat: statMock, + }, + }, + createReadStream: createReadStreamMock, + promises: { + ...actual.promises, + stat: statMock, + }, + }; +}); + +async function loadMediaProtocolModule() { + vi.resetModules(); + createReadStreamMock.mockReset(); + 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, + options?: { method?: 'GET' | 'HEAD'; range?: string } +): Request { + const headers = new Headers(); + + if (options?.range) { + headers.set('range', options.range); + } + + return { + url, + method: options?.method ?? 'GET', + headers, + } as Request; +} + +describe('media-protocol', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-07T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + 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(response.headers.get('Cache-Control')).toBe('no-store'); + expect(response.headers.get('Pragma')).toBe('no-cache'); + 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(response.headers.get('Cache-Control')).toBe('no-store'); + expect(response.headers.get('Pragma')).toBe('no-cache'); + 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(response.headers.get('Cache-Control')).toBe('no-store'); + 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('Cache-Control')).toBe('no-store'); + expect(response.headers.get('Pragma')).toBe('no-cache'); + 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(); + 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); + }); + + 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('Cache-Control')).toBe('no-store'); + expect(explicitRangeResponse.headers.get('Pragma')).toBe('no-cache'); + 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('Cache-Control')).toBe('no-store'); + expect(missingRangeBoundsResponse.headers.get('Pragma')).toBe('no-cache'); + 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(); + }); +}); diff --git a/src/main/utils/__tests__/media-source-authorization.test.ts b/src/main/utils/__tests__/media-source-authorization.test.ts new file mode 100644 index 0000000..d63019a --- /dev/null +++ b/src/main/utils/__tests__/media-source-authorization.test.ts @@ -0,0 +1,138 @@ +import type * as fs from 'fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { accessMock, lstatMock, realpathMock, statMock } = vi.hoisted(() => ({ + accessMock: vi.fn(), + lstatMock: vi.fn(), + realpathMock: vi.fn(), + statMock: vi.fn(), +})); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + const constants = { + R_OK: 4, + }; + + return { + __esModule: true, + ...actual, + default: { + ...actual, + constants, + promises: { + ...actual.promises, + access: accessMock, + lstat: lstatMock, + realpath: realpathMock, + stat: statMock, + }, + }, + constants, + promises: { + ...actual.promises, + access: accessMock, + lstat: lstatMock, + realpath: realpathMock, + stat: statMock, + }, + }; +}); + +async function loadAuthorizationModule() { + vi.resetModules(); + accessMock.mockReset(); + lstatMock.mockReset(); + realpathMock.mockReset(); + statMock.mockReset(); + + return import('../media-source-authorization'); +} + +function useReadableMediaFile(realPath = '/tmp/audio.mp3'): void { + accessMock.mockResolvedValue(undefined); + lstatMock.mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + }); + realpathMock.mockResolvedValue(realPath); + statMock.mockResolvedValue({ isFile: () => true }); +} + +describe('media-source-authorization', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('validates readable, regular media files by canonical path', async () => { + const { validateMediaFilePath } = await loadAuthorizationModule(); + useReadableMediaFile('/private/tmp/movie.mp4'); + + await expect(validateMediaFilePath('/tmp/movie.mp4')).resolves.toEqual({ + success: true, + filePath: '/private/tmp/movie.mp4', + extension: 'mp4', + }); + expect(accessMock).toHaveBeenCalledWith('/private/tmp/movie.mp4', 4); + }); + + it.each(['', 'audio.mp3'])('rejects invalid path %j', async (filePath) => { + const { validateMediaFilePath } = await loadAuthorizationModule(); + + await expect(validateMediaFilePath(filePath)).resolves.toEqual({ + success: false, + error: 'Invalid file path', + }); + expect(lstatMock).not.toHaveBeenCalled(); + }); + + it('rejects symlinks before resolving the real path', async () => { + const { validateMediaFilePath } = await loadAuthorizationModule(); + lstatMock.mockResolvedValue({ + isFile: () => false, + isSymbolicLink: () => true, + }); + + await expect(validateMediaFilePath('/tmp/link.mp3')).resolves.toEqual({ + success: false, + error: 'Unsupported media file', + }); + expect(realpathMock).not.toHaveBeenCalled(); + }); + + it('rejects unsupported extensions and unreadable files', async () => { + const { validateMediaFilePath } = await loadAuthorizationModule(); + useReadableMediaFile('/tmp/notes.txt'); + + await expect(validateMediaFilePath('/tmp/notes.txt')).resolves.toEqual({ + success: false, + error: 'Unsupported media file', + }); + + useReadableMediaFile('/tmp/audio.mp3'); + accessMock.mockRejectedValue(new Error('denied')); + + await expect(validateMediaFilePath('/tmp/audio.mp3')).resolves.toEqual({ + success: false, + error: 'File not found', + }); + }); + + it('requires main-process approval before resolving media preview paths', async () => { + const { approveMediaFilePaths, resolveApprovedMediaFilePath } = await loadAuthorizationModule(); + useReadableMediaFile('/tmp/audio.mp3'); + + await expect(resolveApprovedMediaFilePath('/tmp/audio.mp3')).resolves.toEqual({ + success: false, + error: 'Media file is not approved for preview', + }); + + await approveMediaFilePaths(['/tmp/audio.mp3']); + + await expect(resolveApprovedMediaFilePath('/tmp/audio.mp3')).resolves.toEqual({ + success: true, + filePath: '/tmp/audio.mp3', + extension: 'mp3', + }); + }); +}); diff --git a/src/main/utils/media-protocol.ts b/src/main/utils/media-protocol.ts new file mode 100644 index 0000000..eab79ae --- /dev/null +++ b/src/main/utils/media-protocol.ts @@ -0,0 +1,263 @@ +import { randomUUID } from 'crypto'; +import { protocol } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import { Readable } from 'stream'; + +export const MEDIA_PROTOCOL = 'whisperdesk-media'; + +const MEDIA_SOURCE_TTL_MS = 30 * 60 * 1000; +const MAX_MEDIA_SOURCES = 100; +const NO_STORE_HEADERS = { + 'Cache-Control': 'no-store', + Pragma: 'no-cache', +}; + +interface MediaSourceEntry { + filePath: string; + expiresAt: number; +} + +const mediaSources = new Map(); +let protocolHandlerRegistered = false; + +interface ByteRange { + start: number; + 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, + headers: NO_STORE_HEADERS, + }); + } + + return new Response('Unable to load media source', { + status: 500, + headers: NO_STORE_HEADERS, + }); +} + +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', + ...NO_STORE_HEADERS, + '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', + ...NO_STORE_HEADERS, + '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 entry = getMediaSourceEntry(token); + + if (!entry) { + return new Response('Media source not found', { + status: 404, + headers: NO_STORE_HEADERS, + }); + } + + 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 = randomUUID(); + mediaSources.set(token, { + filePath: resolvedPath, + expiresAt: getMediaSourceExpiration(), + }); + return `${MEDIA_PROTOCOL}://${token}`; +} diff --git a/src/main/utils/media-source-authorization.ts b/src/main/utils/media-source-authorization.ts new file mode 100644 index 0000000..e7a9e71 --- /dev/null +++ b/src/main/utils/media-source-authorization.ts @@ -0,0 +1,95 @@ +import fs from 'fs'; +import path from 'path'; +import { SUPPORTED_EXTENSIONS } from '../../shared/types'; +import type { SupportedExtension } from '../../shared/types'; + +export type MediaPathValidationResult = + | { + success: true; + filePath: string; + extension: SupportedExtension; + } + | { + success: false; + error: string; + }; + +const approvedMediaFilePaths = new Set(); + +function getSupportedMediaExtension(filePath: string): SupportedExtension | null { + const extension = path.extname(filePath).replace('.', '').toLowerCase(); + return SUPPORTED_EXTENSIONS.includes(extension as SupportedExtension) + ? (extension as SupportedExtension) + : null; +} + +export async function validateMediaFilePath(filePath: string): Promise { + 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 normalizedPath = path.resolve(filePath); + let resolvedPath: string; + + try { + const linkStats = await fs.promises.lstat(normalizedPath); + if (linkStats.isSymbolicLink() || !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' }; + } + + try { + await fs.promises.access(resolvedPath, fs.constants.R_OK); + } catch { + return { success: false, error: 'File not found' }; + } + + return { + success: true, + filePath: resolvedPath, + extension, + }; +} + +export async function approveMediaFilePath(filePath: string): Promise { + const validation = await validateMediaFilePath(filePath); + if (validation.success) { + approvedMediaFilePaths.add(validation.filePath); + } +} + +export async function approveMediaFilePaths(filePaths: readonly string[]): Promise { + await Promise.all(filePaths.map((filePath) => approveMediaFilePath(filePath))); +} + +export async function resolveApprovedMediaFilePath( + filePath: string +): Promise { + const validation = await validateMediaFilePath(filePath); + if (!validation.success) { + return validation; + } + + if (!approvedMediaFilePaths.has(validation.filePath)) { + return { success: false, error: 'Media file is not approved for preview' }; + } + + return validation; +} 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..f5b2e98 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', + }) ); }); @@ -874,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/components/OutputDisplay/OutputDisplay.tsx b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx index 02dd9b0..01ee73c 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 { @@ -18,39 +21,95 @@ interface SearchMatch { end: number; } +function mayContainTranscriptSegments(text: string): boolean { + const trimmedText = text.trimStart(); + return trimmedText.startsWith('WEBVTT') || text.includes('-->'); +} + +function findActiveSegmentIndex( + segments: readonly TranscriptSegment[], + playbackTime: number +): number | 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; +} + function OutputDisplay({ text, onSave, onCopy, copySuccess, + selectedFile = null, }: OutputDisplayProps): React.JSX.Element { const [showSearch, setShowSearch] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + const [activeSegmentIndex, setActiveSegmentIndex] = useState(null); + const [isMediaPlayerEnabled, setIsMediaPlayerEnabled] = useState(true); + const mediaRef = useRef(null); + const activeSegmentIndexRef = useRef(null); const hasText = text.length > 0; - const wordCount = hasText ? text.trim().split(/\s+/).length : 0; - const charCount = hasText ? text.length : 0; + const canAttemptMediaMode = hasText && Boolean(selectedFile); + const shouldParseSegments = canAttemptMediaMode && mayContainTranscriptSegments(text); + const segments = useMemo( + () => (shouldParseSegments ? parseTranscriptSegments(text) : []), + [shouldParseSegments, text] + ); + const hasSegments = segments.length > 0; + 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; 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]); + useEffect(() => { const handleKeyDown = (e: globalThis.KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key === 'f' && hasText) { @@ -82,6 +141,10 @@ function OutputDisplay({ } }; + const handleToggleMediaPlayer = (enabled: boolean): void => { + setIsMediaPlayerEnabled(enabled); + }; + const handleCloseSearch = (): void => { setShowSearch(false); setSearchQuery(''); @@ -96,7 +159,7 @@ function OutputDisplay({ }; const highlightedText = useMemo((): React.JSX.Element[] | null => { - if (!searchQuery || !text || matches.length === 0) return null; + if (isMediaModeEnabled || !searchQuery || !text || matches.length === 0) return null; const parts: React.JSX.Element[] = []; let lastIndex = 0; @@ -122,7 +185,48 @@ function OutputDisplay({ } return parts; - }, [text, searchQuery, matches, currentMatchIndex]); + }, [isMediaModeEnabled, text, searchQuery, matches, currentMatchIndex]); + + const updateActiveSegmentForPlayback = useCallback( + (nextPlaybackTime: number): void => { + if (!isMediaModeEnabled) { + return; + } + + const nextActiveSegmentIndex = findActiveSegmentIndex(segments, nextPlaybackTime); + if (activeSegmentIndexRef.current === nextActiveSegmentIndex) { + return; + } + + activeSegmentIndexRef.current = nextActiveSegmentIndex; + setActiveSegmentIndex(nextActiveSegmentIndex); + }, + [isMediaModeEnabled, segments] + ); + + const handleSegmentClick = useCallback((segment: TranscriptSegment): void => { + const media = mediaRef.current; + if (!media) { + return; + } + + media.currentTime = segment.startSec; + activeSegmentIndexRef.current = segment.index; + setActiveSegmentIndex(segment.index); + void media.play().catch(() => {}); + }, []); + + const handleMediaElementChange = useCallback((element: HTMLMediaElement | null): void => { + mediaRef.current = element; + }, []); + + useEffect(() => { + if (!isMediaModeEnabled) { + mediaRef.current = null; + activeSegmentIndexRef.current = null; + setActiveSegmentIndex(null); + } + }, [isMediaModeEnabled]); return (
@@ -135,6 +239,9 @@ function OutputDisplay({ charCount={charCount} onToggleSearch={handleToggleSearch} isSearchActive={showSearch} + showMediaToggle={canUseMediaMode} + isMediaPlayerEnabled={isMediaPlayerEnabled} + onToggleMediaPlayer={handleToggleMediaPlayer} /> {showSearch && hasText && ( @@ -149,12 +256,24 @@ function OutputDisplay({ /> )} + {isMediaModeEnabled && 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..be4bdda 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', () => { @@ -52,6 +55,31 @@ describe('OutputDisplay', () => { expect(wordCountElement).toBeInTheDocument(); }); + 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: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} + /> + ); + + 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 () => { const onSave = vi.fn(); const onCopy = vi.fn(); @@ -806,4 +834,210 @@ describe('OutputDisplay', () => { const searchInput = screen.getByPlaceholderText(/search/i); expect(searchInput).toBeInTheDocument(); }); + + it('should render VTT as plain text when media mode is unavailable', () => { + const onSave = vi.fn(); + const onCopy = vi.fn(); + + render( + 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} + /> + ); + + 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 () => { + 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 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(); + 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(); + window.electronAPI = createFullElectronAPIMock(); + + render( + 00:00:03.000 +alpha beta + +00:00:04.000 --> 00:00:06.000 +beta gamma`} + selectedFile={{ name: 'file.mp3', path: '/path/file.mp3' }} + 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..cb038ae --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.css @@ -0,0 +1,106 @@ +.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 { + display: flex; + 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 minmax(120px, 180px) 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; +} + +.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 auto; + row-gap: 8px; + } + + .transcript-media-volume { + grid-column: 1 / 4; + } + + .transcript-media-speed { + grid-column: 4 / 6; + width: 100%; + } + + .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..77589a9 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/TranscriptMediaPlayer.tsx @@ -0,0 +1,332 @@ +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'; +import type { MediaSourceResult, SelectedFile } from '../../../../types'; +import './TranscriptMediaPlayer.css'; + +export interface TranscriptMediaPlayerProps { + selectedFile: SelectedFile | null; + onMediaElementChange?: (element: HTMLMediaElement | null) => void; + onPlaybackTimeChange: (timeSec: number) => void; +} + +interface MediaSourceState { + filePath: string; + result: MediaSourceResult; +} + +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'; + } + + 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, + onMediaElementChange, + onPlaybackTimeChange, +}: TranscriptMediaPlayerProps): React.JSX.Element | null { + const mediaRef = useRef(null); + const latestPlaybackTimeRef = useRef(0); + const playbackFrameRef = useRef(null); + const [sourceState, setSourceState] = useState(null); + const [isLoading, setIsLoading] = useState(false); + 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); + + const emitPlaybackTimeChange = (nextTime: number): void => { + latestPlaybackTimeRef.current = nextTime; + + if (playbackFrameRef.current !== null) { + return; + } + + playbackFrameRef.current = window.requestAnimationFrame(() => { + playbackFrameRef.current = null; + onPlaybackTimeChange(latestPlaybackTimeRef.current); + }); + }; + + useEffect(() => { + return () => { + if (playbackFrameRef.current !== null) { + window.cancelAnimationFrame(playbackFrameRef.current); + playbackFrameRef.current = null; + } + }; + }, []); + + useEffect(() => { + let isMounted = true; + + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + onPlaybackTimeChange(0); + + if (!selectedFile?.path) { + setSourceState(null); + setIsLoading(false); + return; + } + + const filePath = selectedFile.path; + setIsLoading(true); + void getMediaSource(filePath) + .then((result) => { + if (isMounted) { + setSourceState({ filePath, result }); + } + }) + .catch((error) => { + if (isMounted) { + setSourceState({ + filePath, + result: { + 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 source = sourceState?.filePath === selectedFile.path ? sourceState.result : null; + const isResolvingSource = Boolean(selectedFile.path) && (isLoading || source === 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 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); + emitPlaybackTimeChange(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; + onMediaElementChange?.(element); + + if (element) { + applyVolume(element, volume, isMuted); + element.playbackRate = playbackRate; + } + }; + + if (isResolvingSource) { + 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..aed9d74 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptMediaPlayer/__tests__/TranscriptMediaPlayer.test.tsx @@ -0,0 +1,323 @@ +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'; +import type { MediaSourceResult } from '@/types'; + +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(); + (window as unknown as { electronAPI?: ElectronAPI }).electronAPI = createFullElectronAPIMock(); + }); + + it('renders nothing without a selected file', () => { + const { onMediaElementChange } = createMediaElementChangeHandler(); + 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 { onMediaElementChange } = createMediaElementChangeHandler(); + 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('does not flash unavailable state before resolving a selected media source', () => { + const { onMediaElementChange } = createMediaElementChangeHandler(); + window.electronAPI = { + ...createFullElectronAPIMock(), + getMediaSource: vi.fn((_filePath: string) => new Promise(() => {})), + }; + + render( + + ); + + expect(screen.getByText('Loading media preview...')).toBeInTheDocument(); + expect(screen.queryByText('Media preview unavailable')).not.toBeInTheDocument(); + }); + + it('renders unavailable state for missing media sources', async () => { + const { onMediaElementChange } = createMediaElementChangeHandler(); + 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 { onMediaElementChange } = createMediaElementChangeHandler(); + + render( + + ); + + expect(await screen.findByText('Media preview unavailable')).toBeInTheDocument(); + }); + + it('shows media source errors when source resolution rejects', async () => { + const { onMediaElementChange } = createMediaElementChangeHandler(); + 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 { onMediaElementChange } = createMediaElementChangeHandler(); + 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 { onMediaElementChange } = createMediaElementChangeHandler(); + 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 { onMediaElementChange } = createMediaElementChangeHandler(); + 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); + await waitFor(() => { + 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('supports volume, mute, and playback speed controls', async () => { + 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'); + + 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 { onMediaElementChange } = createMediaElementChangeHandler(); + 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(); + }); + }); + + 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(); + }); +}); 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/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 && ( + + )}