Skip to content

Commit c4b6ffa

Browse files
committed
Fix scan freezes and drag-and-drop loading
1 parent a4391c9 commit c4b6ffa

File tree

4 files changed

+101
-36
lines changed

4 files changed

+101
-36
lines changed

electron/main.ts

Lines changed: 82 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ const SUBTITLE_DIR_KEYWORDS = [
3838
'歌词',
3939
]
4040
const MAX_SUBTITLE_SEARCH_DEPTH = 2
41+
const MAX_SCAN_SUBTITLE_VALIDATION_CANDIDATES = 3
42+
const SCAN_YIELD_INTERVAL = 25
4143

4244
let mainWindow: BrowserWindow | null = null
45+
const subtitleCandidateCache = new Map<string, string[]>()
46+
const subtitleAnalysisCache = new Map<string, { content: string; hasCues: boolean } | null>()
4347

4448
function createWindow() {
4549
mainWindow = new BrowserWindow({
@@ -142,24 +146,35 @@ ipcMain.handle('dialog:openSubtitleFile', async () => {
142146
ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
143147
try {
144148
const files: Array<{ name: string; path: string; type: 'video' | 'audio'; hasScript: boolean; hasSubtitles: boolean; relativePath: string }> = []
149+
let scannedEntries = 0
145150

146-
function scanDir(dir: string, prefix: string) {
151+
const maybeYieldDuringScan = async () => {
152+
scannedEntries += 1
153+
if (scannedEntries % SCAN_YIELD_INTERVAL === 0) {
154+
await new Promise<void>((resolve) => setImmediate(resolve))
155+
}
156+
}
157+
158+
const scanDir = async (dir: string, prefix: string): Promise<void> => {
147159
let entries: fs.Dirent[]
148160
try {
149-
entries = fs.readdirSync(dir, { withFileTypes: true })
161+
entries = await fs.promises.readdir(dir, { withFileTypes: true })
150162
} catch {
151163
return
152164
}
165+
153166
for (const entry of entries) {
167+
await maybeYieldDuringScan()
168+
154169
const fullPath = path.join(dir, entry.name)
155170
if (entry.isDirectory()) {
156-
scanDir(fullPath, prefix ? prefix + '/' + entry.name : entry.name)
171+
await scanDir(fullPath, prefix ? prefix + '/' + entry.name : entry.name)
157172
} else if (entry.isFile()) {
158173
const ext = path.extname(entry.name).toLowerCase()
159174
if (MEDIA_EXTS.includes(ext)) {
160175
const baseName = path.basename(entry.name, ext)
161176
const scriptPath = path.join(dir, baseName + '.funscript')
162-
const hasSubtitles = findSubtitleFilesForMedia(fullPath).length > 0
177+
const hasSubtitles = hasSubtitlesForMediaScan(fullPath)
163178
files.push({
164179
name: entry.name,
165180
path: fullPath,
@@ -173,7 +188,7 @@ ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
173188
}
174189
}
175190

176-
scanDir(dirPath, '')
191+
await scanDir(dirPath, '')
177192
return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
178193
} catch {
179194
return []
@@ -319,37 +334,63 @@ function findArtworkForMedia(mediaPath: string): string | null {
319334
}
320335

321336
function findSubtitleFilesForMedia(mediaPath: string): string[] {
337+
return findSubtitleMatches(mediaPath, 'full')
338+
}
339+
340+
function hasSubtitlesForMediaScan(mediaPath: string): boolean {
341+
return findSubtitleMatches(mediaPath, 'scan').length > 0
342+
}
343+
344+
function findSubtitleMatches(mediaPath: string, mode: 'scan' | 'full'): string[] {
322345
const mediaDir = path.dirname(mediaPath)
323346
const ext = path.extname(mediaPath)
324347
const baseName = path.basename(mediaPath, ext).toLowerCase()
325348
const mediaType = VIDEO_EXTS.includes(ext.toLowerCase()) ? 'video' : 'audio'
326349

327-
return collectSubtitleCandidates(mediaDir)
350+
const rankedCandidates = collectSubtitleCandidates(mediaDir)
328351
.map((filePath) => {
329-
const content = readSubtitleContent(filePath)
330-
const cues = parseSubtitleFile(content, filePath)
331-
if (cues.length === 0) return null
332-
333-
const fileScore = scoreSubtitleCandidate(filePath, mediaDir, baseName)
334-
const videoScore = mediaType === 'video'
335-
? getVideoSubtitleMatchScore(mediaPath, { path: filePath, content })
336-
: 0
337-
338-
if (mediaType === 'video' && videoScore < 0) {
339-
return null
340-
}
341-
342352
return {
343353
filePath,
344-
score: fileScore + videoScore,
354+
score: scoreSubtitleCandidate(filePath, mediaDir, baseName),
345355
}
346356
})
347-
.filter((entry): entry is { filePath: string; score: number } => entry !== null)
357+
.sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath))
358+
const candidatesToValidate = mode === 'scan'
359+
? rankedCandidates.slice(0, MAX_SCAN_SUBTITLE_VALIDATION_CANDIDATES)
360+
: rankedCandidates
361+
const matches: Array<{ filePath: string; score: number }> = []
362+
363+
for (const candidate of candidatesToValidate) {
364+
const analysis = readSubtitleAnalysis(candidate.filePath)
365+
if (!analysis?.hasCues) continue
366+
367+
let score = candidate.score
368+
if (mediaType === 'video') {
369+
const videoScore = getVideoSubtitleMatchScore(mediaPath, {
370+
path: candidate.filePath,
371+
content: analysis.content,
372+
})
373+
if (videoScore < 0) continue
374+
score += videoScore
375+
}
376+
377+
matches.push({
378+
filePath: candidate.filePath,
379+
score,
380+
})
381+
}
382+
383+
return matches
348384
.sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath))
349385
.map(({ filePath }) => filePath)
350386
}
351387

352388
function collectSubtitleCandidates(rootDir: string): string[] {
389+
const cached = subtitleCandidateCache.get(rootDir)
390+
if (cached) {
391+
return cached
392+
}
393+
353394
const results = new Set<string>()
354395
const visited = new Set<string>()
355396

@@ -384,7 +425,9 @@ function collectSubtitleCandidates(rootDir: string): string[] {
384425
}
385426

386427
walk(rootDir, 0, false)
387-
return Array.from(results)
428+
const collected = Array.from(results)
429+
subtitleCandidateCache.set(rootDir, collected)
430+
return collected
388431
}
389432

390433
function directoryLooksLikeSubtitle(name: string): boolean {
@@ -486,6 +529,23 @@ function readSubtitleContent(filePath: string): string {
486529
return utf8
487530
}
488531

532+
function readSubtitleAnalysis(filePath: string): { content: string; hasCues: boolean } | null {
533+
if (subtitleAnalysisCache.has(filePath)) {
534+
return subtitleAnalysisCache.get(filePath) ?? null
535+
}
536+
537+
try {
538+
const content = readSubtitleContent(filePath)
539+
const hasCues = parseSubtitleFile(content, filePath).length > 0
540+
const analysis = { content, hasCues }
541+
subtitleAnalysisCache.set(filePath, analysis)
542+
return analysis
543+
} catch {
544+
subtitleAnalysisCache.set(filePath, null)
545+
return null
546+
}
547+
}
548+
489549
function countReplacementChars(value: string): number {
490550
return (value.match(/\uFFFD/g) ?? []).length
491551
}

electron/preload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { contextBridge, ipcRenderer } from 'electron'
1+
import { contextBridge, ipcRenderer, webUtils } from 'electron'
22

33
contextBridge.exposeInMainWorld('electronAPI', {
44
platform: process.platform,
@@ -13,6 +13,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
1313
openFolder: () => ipcRenderer.invoke('dialog:openFolder'),
1414
openScriptFile: () => ipcRenderer.invoke('dialog:openScriptFile'),
1515
openSubtitleFile: () => ipcRenderer.invoke('dialog:openSubtitleFile'),
16+
getDroppedFilePath: (file: File) => webUtils.getPathForFile(file),
1617

1718
// File system
1819
readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path),

src/App.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -107,23 +107,26 @@ export default function App() {
107107
setSubtitleCues([])
108108
setScriptUploadUrl(null)
109109

110-
const url = await window.electronAPI.getVideoUrl(filePath)
111-
setVideoUrl(url)
112110
setArtworkUrl(null)
113111

114-
if (resolvedType === 'audio') {
115-
const artworkPath = await window.electronAPI.findArtwork(filePath)
116-
if (artworkPath) {
117-
const nextArtworkUrl = await window.electronAPI.getVideoUrl(artworkPath)
118-
setArtworkUrl(nextArtworkUrl)
119-
}
120-
}
121-
122-
setSubtitleCues(await loadSubtitleCues(filePath, resolvedType))
112+
const [url, nextSubtitleCues, parsed, artworkPath] = await Promise.all([
113+
window.electronAPI.getVideoUrl(filePath),
114+
loadSubtitleCues(filePath, resolvedType),
115+
loadScript(filePath),
116+
resolvedType === 'audio'
117+
? window.electronAPI.findArtwork(filePath)
118+
: Promise.resolve<string | null>(null),
119+
])
123120

124-
const parsed = await loadScript(filePath)
121+
setVideoUrl(url)
122+
setSubtitleCues(nextSubtitleCues)
125123
setFunscript(parsed)
126124

125+
if (artworkPath) {
126+
const nextArtworkUrl = await window.electronAPI.getVideoUrl(artworkPath)
127+
setArtworkUrl(nextArtworkUrl)
128+
}
129+
127130
if (parsed && handyService.isConnected) {
128131
uploadToHandy(parsed.actions)
129132
}
@@ -282,7 +285,7 @@ export default function App() {
282285
const file = files[0]
283286
const mediaType = getMediaTypeFromPath(file.name)
284287
if (mediaType) {
285-
const path = (file as any).path as string
288+
const path = window.electronAPI.getDroppedFilePath(file) || (file as any).path as string
286289
if (path) {
287290
await openMediaFile(path, mediaType)
288291
}

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ declare global {
6969
openFolder: () => Promise<string | null>
7070
openScriptFile: () => Promise<string | null>
7171
openSubtitleFile: () => Promise<string | null>
72+
getDroppedFilePath: (file: File) => string
7273
readDir: (path: string) => Promise<VideoFile[]>
7374
readFunscript: (videoPath: string, scriptFolder?: string) => Promise<Funscript | null>
7475
readFunscriptFile: (filePath: string) => Promise<Funscript | null>

0 commit comments

Comments
 (0)