fixed finished downloads

This commit is contained in:
Linrador 2026-06-06 14:58:55 +02:00
parent 1625952638
commit 1a559fe76d
8 changed files with 227 additions and 191 deletions

View File

@ -224,6 +224,21 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Neuer Cleanup-Abschnitt: alten 100%-Fortschritt vom Small-Files-Scan zurücksetzen.
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
if st.StartedAt.IsZero() {
st.StartedAt = time.Now()
}
st.Done = 0
st.Total = 0
st.CurrentFile = ""
st.Text = "Räume Record-Reste auf…"
st.Error = ""
st.FinishedAt = nil
})
if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil { if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil {
finishCleanupJob(jobID, "", err) finishCleanupJob(jobID, "", err)
return return
@ -236,7 +251,19 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
default: default:
} }
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
st.Done = 0
st.Total = 0
st.CurrentFile = ""
st.Text = "Räume Generated-Assets auf…"
st.Error = ""
st.FinishedAt = nil
})
gcStats := triggerGeneratedGarbageCollectorSync() gcStats := triggerGeneratedGarbageCollectorSync()
resp.GeneratedOrphansChecked = gcStats.Checked resp.GeneratedOrphansChecked = gcStats.Checked
resp.GeneratedOrphansRemoved = gcStats.Removed resp.GeneratedOrphansRemoved = gcStats.Removed
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
@ -494,8 +521,14 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
} }
updateCleanupJobState(jobID, func(st *CleanupTaskState) { updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
st.Done = 0
st.Total = 0
st.CurrentFile = name st.CurrentFile = name
st.Text = "Räume Record-Reste auf…" st.Text = "Räume Record-Reste auf…"
st.Error = ""
st.FinishedAt = nil
}) })
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) { if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {

View File

@ -108,7 +108,7 @@ func runTray(onQuit func(), statsFn func() TrayStats) {
updateStatus() updateStatus()
case <-mOpenFrontend.ClickedCh: case <-mOpenFrontend.ClickedCh:
_ = openBrowser("http://localhost:9999") _ = openBrowser("https://localhost:9999")
case <-mOpenRecordDir.ClickedCh: case <-mOpenRecordDir.ClickedCh:
_ = openFolder(resolveConfiguredDir(getSettings().RecordDir)) _ = openFolder(resolveConfiguredDir(getSettings().RecordDir))

View File

@ -914,10 +914,16 @@ export default function App() {
type DonePrefetch = { type DonePrefetch = {
key: string key: string
items: RecordJob[] items: RecordJob[]
totalCount: number
ts: number ts: number
} }
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}` const makePrefetchKey = (
page: number,
sort: DoneSortMode,
pageSize: number,
keep: boolean
) => `${sort}::${pageSize}::${keep ? 1 : 0}::${page}`
const getDoneTotalPages = (count: number, pageSize = donePageSize) => { const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
const safeCount = Number.isFinite(count) && count > 0 ? count : 0 const safeCount = Number.isFinite(count) && count > 0 ? count : 0
@ -928,7 +934,7 @@ export default function App() {
if (pageToFetch < 1) return if (pageToFetch < 1) return
if (donePrefetchInFlightRef.current) return if (donePrefetchInFlightRef.current) return
const key = makePrefetchKey(pageToFetch, doneSort) const key = makePrefetchKey(pageToFetch, doneSort, donePageSize, includeKeep)
const cur = donePrefetchRef.current const cur = donePrefetchRef.current
if (cur?.key === key && Date.now() - cur.ts < 15_000) { if (cur?.key === key && Date.now() - cur.ts < 15_000) {
// frisch genug // frisch genug
@ -938,7 +944,7 @@ export default function App() {
donePrefetchInFlightRef.current = true donePrefetchInFlightRef.current = true
try { try {
const res = await fetch( const res = await fetch(
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${ `/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}&withCount=1${
includeKeep ? '&includeKeep=1' : '' includeKeep ? '&includeKeep=1' : ''
}`, }`,
{ cache: 'no-store' as any } { cache: 'no-store' as any }
@ -952,66 +958,23 @@ export default function App() {
? (data as RecordJob[]) ? (data as RecordJob[])
: [] : []
donePrefetchRef.current = { key, items, ts: Date.now() } const totalCountRaw = Number(data?.count ?? data?.totalCount ?? 0)
const totalCount =
Number.isFinite(totalCountRaw) && totalCountRaw >= 0
? totalCountRaw
: 0
donePrefetchRef.current = {
key,
items,
totalCount,
ts: Date.now(),
}
} finally { } finally {
donePrefetchInFlightRef.current = false donePrefetchInFlightRef.current = false
} }
}, [doneSort, includeKeep, donePageSize]) }, [doneSort, includeKeep, donePageSize])
function doneJobMergeKey(job: RecordJob): string {
const output = String(job?.output ?? '').trim()
const id = String((job as any)?.id ?? '').trim()
if (output) return output
if (id) return id
return JSON.stringify([
String((job as any)?.sourceUrl ?? ''),
String((job as any)?.startedAt ?? ''),
String((job as any)?.endedAt ?? ''),
])
}
function mergeDoneJobsStable(
prev: RecordJob[],
incoming: RecordJob[]
): RecordJob[] {
if (!Array.isArray(prev) || prev.length === 0) return incoming
if (!Array.isArray(incoming) || incoming.length === 0) return prev
const incomingByKey = new Map<string, RecordJob>()
for (const item of incoming) {
incomingByKey.set(doneJobMergeKey(item), item)
}
const seen = new Set<string>()
const merged: RecordJob[] = []
// 1) Bestehende sichtbare Reihenfolge beibehalten,
// aber Daten der vorhandenen Elemente aktualisieren.
for (const oldItem of prev) {
const key = doneJobMergeKey(oldItem)
const nextItem = incomingByKey.get(key)
if (nextItem) {
merged.push(nextItem)
seen.add(key)
} else {
merged.push(oldItem)
}
}
// 2) Wirklich neue Finished-Downloads hinten anhängen,
// ohne bestehende Karten umzuschichten.
for (const item of incoming) {
const key = doneJobMergeKey(item)
if (seen.has(key)) continue
merged.push(item)
}
return merged
}
const loadDoneCount = useCallback(async () => { const loadDoneCount = useCallback(async () => {
const now = Date.now() const now = Date.now()
@ -1043,20 +1006,33 @@ export default function App() {
const loadDonePage = useCallback(async ( const loadDonePage = useCallback(async (
pageToLoad = donePage, pageToLoad = donePage,
sortToLoad = doneSort, sortToLoad = doneSort
opts?: { preserveOrder?: boolean }
) => { ) => {
try { try {
const preserveOrder = Boolean(opts?.preserveOrder) const key = makePrefetchKey(
const key = makePrefetchKey(pageToLoad, sortToLoad) pageToLoad,
sortToLoad,
donePageSize,
includeKeep
)
const prefetched = donePrefetchRef.current const prefetched = donePrefetchRef.current
if (prefetched?.key === key && Array.isArray(prefetched.items)) { if (prefetched?.key === key && Array.isArray(prefetched.items)) {
setDoneJobs((prev) => setDoneJobs(prefetched.items)
preserveOrder ? mergeDoneJobsStable(prev, prefetched.items) : prefetched.items
) const count = Number(prefetched.totalCount)
if (Number.isFinite(count) && count >= 0 && count !== doneCount) {
setDoneCount(count)
}
donePrefetchRef.current = null donePrefetchRef.current = null
const totalPages = getDoneTotalPages(count, donePageSize)
if (pageToLoad < totalPages) {
void prefetchDonePage(pageToLoad + 1)
}
return return
} }
@ -1066,6 +1042,7 @@ export default function App() {
}`, }`,
{ cache: 'no-store' as any } { cache: 'no-store' as any }
) )
if (!res.ok) return if (!res.ok) return
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
@ -1074,9 +1051,7 @@ export default function App() {
? (data.items as RecordJob[]) ? (data.items as RecordJob[])
: [] : []
setDoneJobs((prev) => setDoneJobs(items)
preserveOrder ? mergeDoneJobsStable(prev, items) : items
)
const countRaw = Number(data?.count ?? doneCount) const countRaw = Number(data?.count ?? doneCount)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
@ -1094,6 +1069,12 @@ export default function App() {
} }
}, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount]) }, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])
const loadDonePageRef = useRef(loadDonePage)
useEffect(() => {
loadDonePageRef.current = loadDonePage
}, [loadDonePage])
useEffect(() => { useEffect(() => {
try { try {
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0') window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
@ -2773,7 +2754,8 @@ export default function App() {
void loadTaskStateOnce() void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current) donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
} }
}, document.hidden ? 60000 : 5000) }, document.hidden ? 60000 : 5000)
} }
@ -2802,9 +2784,8 @@ export default function App() {
void loadDoneCount() void loadDoneCount()
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current, { donePrefetchRef.current = null
preserveOrder: true, void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
})
} }
} }
@ -3009,7 +2990,8 @@ export default function App() {
void loadAutostartState().catch(() => {}) void loadAutostartState().catch(() => {})
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current) donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
} }
} }
@ -3306,47 +3288,6 @@ export default function App() {
} }
} }
const fillDonePageFromPrefetch = useCallback((opts?: { removedFile?: string }) => {
const removedFile = String(opts?.removedFile ?? '').trim()
setDoneJobs((prev) => {
const filtered = removedFile
? prev.filter((j) => baseName(j.output || '') !== removedFile)
: [...prev]
const need = Math.max(0, donePageSize - filtered.length)
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(
next.map((x) => String(x.id || baseName(x.output || '')).trim())
)
while (next.length < donePageSize && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
donePrefetchRef.current = {
...buf,
items: buf.items,
ts: buf.ts,
}
return next
})
}, [donePage, donePageSize, doneSort])
const doneCountRef = useRef(doneCount) const doneCountRef = useRef(doneCount)
useEffect(() => { useEffect(() => {
@ -3369,18 +3310,12 @@ export default function App() {
), ),
onSuccess: async () => { onSuccess: async () => {
window.setTimeout(() => { window.setTimeout(() => {
fillDonePageFromPrefetch({ removedFile: file })
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
const nextDoneCount = Math.max(0, doneCountRef.current - 1) donePrefetchRef.current = null
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize)
if (donePage < totalPagesAfterDelete) {
void prefetchDonePage(donePage + 1)
}
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount() void loadDoneCount()
}, 320) }, 320)
}, },
@ -3400,9 +3335,6 @@ export default function App() {
[ [
runFinishedFileAction, runFinishedFileAction,
notify, notify,
fillDonePageFromPrefetch,
donePage,
prefetchDonePage,
loadDoneCount, loadDoneCount,
] ]
) )
@ -3423,9 +3355,12 @@ export default function App() {
), ),
onSuccess: async () => { onSuccess: async () => {
window.setTimeout(() => { window.setTimeout(() => {
fillDonePageFromPrefetch({ removedFile: file })
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount() void loadDoneCount()
}, 320) }, 320)
}, },
@ -3439,7 +3374,7 @@ export default function App() {
return return
} }
}, },
[runFinishedFileAction, notify, fillDonePageFromPrefetch, loadDoneCount] [runFinishedFileAction, notify, loadDoneCount]
) )
const handleToggleHot = useCallback( const handleToggleHot = useCallback(
@ -3493,13 +3428,18 @@ export default function App() {
}) })
) )
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount()
return res return res
} catch (e: any) { } catch (e: any) {
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output)) notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
return return
} }
}, },
[notify] [notify, loadDoneCount]
) )
const handleDeleteJob = useCallback( const handleDeleteJob = useCallback(
@ -4541,7 +4481,8 @@ export default function App() {
} }
bumpAssets() bumpAssets()
void loadDonePage(donePageRef.current, doneSortRef.current) donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount() void loadDoneCount()
} catch (err) { } catch (err) {
const message = const message =

View File

@ -1850,62 +1850,50 @@ function RatingHeroHeader({
toneLabel, toneLabel,
segmentsCount, segmentsCount,
entriesCount, entriesCount,
onClose,
}: { }: {
stars: number stars: number
toneLabel: string toneLabel: string
segmentsCount: number segmentsCount: number
entriesCount: number entriesCount: number
onClose?: () => void
}) { }) {
const tone = ratingTone(stars) const tone = ratingTone(stars)
const progress = Math.max(0, Math.min(100, Math.round((stars / 5) * 100))) const progress = Math.max(0, Math.min(100, Math.round((stars / 5) * 100)))
return ( return (
<div className="relative overflow-hidden rounded-xl border border-white/10 bg-gray-950 px-3 py-2 text-white shadow-lg"> <div
<div className="absolute -right-8 -top-8 size-20 rounded-full bg-amber-400/12 blur-2xl" /> className={[
<div className="absolute -left-8 bottom-0 size-16 rounded-full bg-indigo-500/10 blur-2xl" /> 'relative overflow-hidden rounded-xl border px-3 py-2 shadow-sm',
'border-gray-200 bg-white text-gray-950 ring-1 ring-black/5',
'dark:border-white/10 dark:bg-gray-950 dark:text-white dark:ring-white/10',
].join(' ')}
>
<div className="absolute -right-8 -top-8 size-20 rounded-full bg-amber-300/20 blur-2xl dark:bg-amber-400/12" />
<div className="absolute -left-8 bottom-0 size-16 rounded-full bg-indigo-300/15 blur-2xl dark:bg-indigo-500/10" />
<div className="relative flex items-center justify-between gap-3"> <div className="relative flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="truncate text-[13px] font-black leading-4 tracking-tight"> <div className="truncate text-[13px] font-black leading-4 tracking-tight text-gray-950 dark:text-white">
AI Rating AI Rating
</div> </div>
<span className="shrink-0 rounded-full bg-white/10 px-1.5 py-[2px] text-[9px] font-bold leading-none text-white/75 ring-1 ring-white/15"> <span className="shrink-0 rounded-full bg-gray-100 px-1.5 py-[2px] text-[9px] font-bold leading-none text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-white/75 dark:ring-white/15">
{toneLabel} {toneLabel}
</span> </span>
</div> </div>
<div className="mt-0.5 truncate text-[10px] leading-3 text-white/55"> <div className="mt-0.5 truncate text-[10px] leading-3 text-gray-500 dark:text-white/55">
{stars}/5 · {segmentsCount} Stellen · {entriesCount} Details {stars}/5 · {segmentsCount} Stellen · {entriesCount} Details
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
<RatingStar stars={stars} className="h-7 w-7" /> <RatingStar stars={stars} className="h-7 w-7" />
{onClose ? (
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-lg leading-none text-white/75 ring-1 ring-white/15 transition hover:bg-white/18 hover:text-white"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose()
}}
aria-label="Schließen"
title="Schließen"
>
×
</button>
) : null}
</div> </div>
</div> </div>
<div className="relative mt-2"> <div className="relative mt-2">
<div className="h-1 overflow-hidden rounded-full bg-white/12"> <div className="h-1 overflow-hidden rounded-full bg-gray-200 dark:bg-white/12">
<div <div
className={[ className={[
'h-full rounded-full transition-all duration-300', 'h-full rounded-full transition-all duration-300',
@ -2328,7 +2316,6 @@ function RatingMobileSheet({
toneLabel={toneLabel} toneLabel={toneLabel}
segmentsCount={segments.length} segmentsCount={segments.length}
entriesCount={entries.length} entriesCount={entries.length}
onClose={closeWithAnimation}
/> />
</div> </div>

View File

@ -223,7 +223,12 @@ function SettingsSection(props: {
<button <button
type="button" type="button"
onClick={toggleOpen} onClick={toggleOpen}
className="flex w-full items-start justify-between gap-4 rounded-2xl bg-white/95 p-4 text-left transition hover:bg-gray-50 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400" className={[
'flex w-full items-start justify-between gap-4 bg-white/95 p-4 text-left transition hover:bg-gray-50',
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500',
'dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400',
open ? 'rounded-t-2xl rounded-b-none' : 'rounded-2xl',
].join(' ')}
aria-expanded={open} aria-expanded={open}
> >
<div className="min-w-0"> <div className="min-w-0">

View File

@ -498,17 +498,20 @@ export function ToastProvider({
const ctx = useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear]) const ctx = useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
const posCls = const desktopPosCls =
position === 'top-right' position.startsWith('top')
? 'items-start sm:items-start sm:justify-start' ? 'sm:items-start'
: position === 'top-left' : 'sm:items-end'
? 'items-start sm:items-start sm:justify-start'
: position === 'bottom-left'
? 'items-end sm:items-end sm:justify-end'
: 'items-end sm:items-end sm:justify-end'
const alignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end' const desktopAlignCls = position.endsWith('left')
const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'bottom-0 top-auto' ? 'sm:items-start'
: 'sm:items-end'
// Mobile immer oben mittig.
// Desktop bleibt wie über `position` gesteuert.
const insetCls = position.startsWith('top')
? 'top-0 bottom-auto'
: 'top-0 bottom-auto sm:bottom-0 sm:top-auto'
return ( return (
<ToastContext.Provider value={ctx}> <ToastContext.Provider value={ctx}>
@ -534,8 +537,18 @@ export function ToastProvider({
aria-live="assertive" aria-live="assertive"
className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')} className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
> >
<div className={['flex w-full px-3 py-4 sm:px-6 sm:py-6', posCls].join(' ')}> <div
<div className={['flex w-full flex-col gap-2.5 sm:gap-3', alignCls].join(' ')}> className={[
'flex w-full justify-center px-3 py-4 sm:px-6 sm:py-6',
desktopPosCls,
].join(' ')}
>
<div
className={[
'flex w-full max-w-[22rem] flex-col items-center gap-2.5 sm:max-w-none sm:gap-3',
desktopAlignCls,
].join(' ')}
>
{toasts.map((t) => { {toasts.map((t) => {
const { Icon, cls } = iconFor(t.type) const { Icon, cls } = iconFor(t.type)
const accents = accentFor(t.type) const accents = accentFor(t.type)

View File

@ -5305,8 +5305,33 @@ export default function TrainingTab(props: {
if (!rect || rect.width <= 0 || rect.height <= 0) return null if (!rect || rect.width <= 0 || rect.height <= 0) return null
const size = 156 const visualViewport =
const padding = 20 typeof window !== 'undefined' ? window.visualViewport : undefined
const viewportLeft = visualViewport?.offsetLeft ?? 0
const viewportTop = visualViewport?.offsetTop ?? 0
const viewportW =
visualViewport?.width ??
(typeof window !== 'undefined' ? window.innerWidth : 390)
const viewportH =
visualViewport?.height ??
(typeof window !== 'undefined' ? window.innerHeight : 800)
const isCoarsePointer =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches
const isTouchLike = isCoarsePointer || viewportW < 640
const baseSize = isTouchLike ? 136 : 156
const largeBoxSize = isTouchLike
? Math.min(176, Math.max(152, viewportW - 32))
: 190
const padding = isTouchLike ? 16 : 20
const activeBox = const activeBox =
drawingBox || drawingBox ||
@ -5332,29 +5357,70 @@ export default function TrainingTab(props: {
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0 const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0 const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0
const boxNeedsLargeMagnifier =
hasUsableBox &&
(
boxPixelW > baseSize - padding * 2 ||
boxPixelH > baseSize - padding * 2
)
const size = boxNeedsLargeMagnifier ? largeBoxSize : baseSize
const fitPadding = isTouchLike ? 14 : 18
const fitZoom = hasUsableBox const fitZoom = hasUsableBox
? Math.min( ? Math.min(
(size - padding * 2) / Math.max(1, boxPixelW), (size - fitPadding * 2) / Math.max(1, boxPixelW),
(size - padding * 2) / Math.max(1, boxPixelH) (size - fitPadding * 2) / Math.max(1, boxPixelH)
) )
: 2 : 2
const zoom = hasUsableBox const zoom = hasUsableBox
? Math.max(0.55, Math.min(2.25, fitZoom)) ? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94)
: 2 : 2
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390 const gap = isTouchLike ? 10 : 12
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800 const edgeGap = isTouchLike ? 8 : 10
let left = touchMagnifier.clientX - size / 2 // Die Lupe wird an der Box ausgerichtet, nicht am Finger.
let top = touchMagnifier.clientY - size - 28 // Wenn gerade noch keine echte Box existiert, fällt sie auf den aktuellen Bildpunkt zurück.
const anchorX = hasUsableBox
? rect.left + boxCenterX * rect.width
: rect.left + touchMagnifier.imageX * rect.width
if (top < 8) { const anchorTop = hasUsableBox
top = touchMagnifier.clientY + 28 ? rect.top + activeBox.y * rect.height
: rect.top + touchMagnifier.imageY * rect.height
const anchorBottom = hasUsableBox
? rect.top + (activeBox.y + activeBox.h) * rect.height
: rect.top + touchMagnifier.imageY * rect.height
let left = anchorX - size / 2
// Normalfall: Lupe über der Box.
let top = anchorTop - size - gap
// Wenn oben kein Platz ist: Lupe unter die Box setzen.
if (top < viewportTop + edgeGap) {
top = anchorBottom + gap
} }
left = Math.max(8, Math.min(viewportW - size - 8, left)) // Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde:
top = Math.max(8, Math.min(viewportH - size - 8, top)) // an das untere Ende des sichtbaren Viewports klemmen.
if (top + size > viewportTop + viewportH - edgeGap) {
top = viewportTop + viewportH - size - edgeGap
}
// Falls der Viewport extrem klein ist, trotzdem sichtbar halten.
if (top < viewportTop + edgeGap) {
top = viewportTop + edgeGap
}
left = Math.max(
viewportLeft + edgeGap,
Math.min(viewportLeft + viewportW - size - edgeGap, left)
)
const imageWidth = rect.width * zoom const imageWidth = rect.width * zoom
const imageHeight = rect.height * zoom const imageHeight = rect.height * zoom
@ -5548,15 +5614,6 @@ export default function TrainingTab(props: {
</Button> </Button>
</div> </div>
</div> </div>
<div
className={[
'mt-2 hidden text-center text-xs text-gray-500 dark:text-gray-400',
imageExpanded ? 'lg:hidden' : 'lg:block',
].join(' ')}
>
Prüfe das Bild. Wenn die Erkennung stimmt: Passt so. Wenn nicht: korrigieren und speichern.
</div>
</section> </section>
{trainingRunning ? ( {trainingRunning ? (