updated finisheddownloads

This commit is contained in:
Linrador 2026-03-16 15:11:45 +01:00
parent dffe5dde16
commit 6682b51f76
10 changed files with 154 additions and 115 deletions

View File

@ -695,7 +695,7 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
}
// ffmpeg args: input = m3u8, output = fragmented mp4 to stdout
// ✅ nur Video (kein Audio), damit MSE codecs stabil sind
// ✅ Video + Audio für Browser-Playback
args := []string{"-hide_banner", "-loglevel", "error"}
if ua != "" {
args = append(args, "-user_agent", ua)
@ -707,9 +707,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
// Input
args = append(args, "-i", m3u8)
// Video encode (low-latency-ish)
// Video + Audio encode (low-latency-ish)
args = append(args,
"-an",
"-map", "0:v:0",
"-map", "0:a:0?",
"-vf", "scale=480:-2",
"-c:v", "libx264",
"-preset", "veryfast",
@ -720,6 +721,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
"-g", "48",
"-keyint_min", "48",
"-sc_threshold", "0",
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-ar", "48000",
)
// Output: fMP4 fragmented to stdout (single HTTP response)

View File

@ -10,7 +10,6 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@ -60,22 +59,6 @@ func RecordStream(
jobsMu.Unlock()
}
if job != nil && strings.TrimSpace(job.PreviewDir) == "" {
assetID := assetIDForJob(job)
if strings.TrimSpace(assetID) == "" {
assetID = job.ID
}
previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID)
jobsMu.Lock()
job.PreviewDir = previewDir
jobsMu.Unlock()
if err := startPreviewHLS(ctx, job, playlist.PlaylistURL, previewDir, httpCookie, hc.userAgent); err != nil {
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
}
}
// 4) Datei öffnen
file, err := os.Create(outputPath)
if err != nil {

View File

@ -12,7 +12,6 @@ import (
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@ -89,22 +88,6 @@ func RecordStreamMFC(
_ = publishJob(job.ID)
}
// ✅ Preview starten
if job != nil && job.PreviewDir == "" {
assetID := assetIDForJob(job)
if strings.TrimSpace(assetID) == "" {
assetID = job.ID
}
previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID)
job.PreviewDir = previewDir
if err := startPreviewHLS(ctx, job, m3u8URL, previewDir, "", hc.userAgent); err != nil {
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
job.PreviewDir = "" // rollback
}
}
// Aufnahme starten
return handleM3U8Mode(ctx, m3u8URL, outputPath, job)
}

View File

@ -13,7 +13,7 @@
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "hover",
"teaserAudio": false,
"teaserAudio": true,
"enableNotifications": true,
"encryptedCookies": ""
}

View File

@ -171,10 +171,10 @@ func loadSettings() {
// ffmpeg-Pfad anhand Settings/Env/PATH bestimmen
ffmpegPath = detectFFmpegPath()
//fmt.Println("🔍 ffmpegPath:", ffmpegPath)
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
ffprobePath = detectFFprobePath()
//fmt.Println("🔍 ffprobePath:", ffprobePath)
fmt.Println("🔍 ffprobePath:", ffprobePath)
}
@ -403,10 +403,10 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
} else {
ffmpegPath = detectFFmpegPath()
}
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
//fmt.Println("🔍 ffmpegPath:", ffmpegPath)
ffprobePath = detectFFprobePath()
fmt.Println("🔍 ffprobePath:", ffprobePath)
//fmt.Println("🔍 ffprobePath:", ffprobePath)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")

View File

@ -27,6 +27,7 @@ import { isHotName, stripHotPrefix } from './hotName'
import LabeledSwitch from './LabeledSwitch'
import Switch from './Switch'
import LoadingSpinner from './LoadingSpinner'
import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback'
type SortMode =
| 'completed_desc'
@ -38,8 +39,6 @@ type SortMode =
| 'size_desc'
| 'size_asc'
type TeaserPlaybackMode = 'still' | 'hover' | 'all'
type Props = {
jobs: RecordJob[]
doneJobs: RecordJob[]
@ -357,6 +356,16 @@ export default function FinishedDownloads({
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
const refillSessionRef = React.useRef(0)
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
() => ({
mode: teaserPlaybackMode,
activeKey: teaserKey,
hoverKey: hoverTeaserKey,
audioEnabled: Boolean(teaserAudio),
}),
[teaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
)
type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
@ -1928,7 +1937,7 @@ export default function FinishedDownloads({
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
useEffect(() => {
const active =
teaserPlaybackMode === 'hover' &&
teaserState.mode === 'hover' &&
!canHover &&
(view === 'cards' || view === 'gallery' || view === 'table')
@ -1985,7 +1994,7 @@ export default function FinishedDownloads({
io.disconnect()
if (teaserIORef.current === io) teaserIORef.current = null
}
}, [view, teaserPlaybackMode, canHover, inlinePlay?.key])
}, [view, teaserState.mode, canHover, inlinePlay?.key])
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
const runtimeOf = (job: RecordJob): string => {
@ -2456,10 +2465,7 @@ export default function FinishedDownloads({
isLoading={isLoading}
blurPreviews={blurPreviews}
durations={durations}
teaserKey={teaserKey}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
teaserState={teaserState}
inlinePlay={inlinePlay}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
@ -2515,11 +2521,8 @@ export default function FinishedDownloads({
resolutions={resolutions}
durations={durations}
canHover={canHover}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
teaserState={teaserState}
setHoverTeaserKey={setHoverTeaserKey}
teaserPlayback={teaserPlaybackMode}
teaserKey={teaserKey}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
handleResolution={handleResolution}
@ -2556,10 +2559,7 @@ export default function FinishedDownloads({
blurPreviews={blurPreviews}
durations={durations}
handleDuration={handleDuration}
teaserKey={teaserKey}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
teaserState={teaserState}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}

View File

@ -11,6 +11,11 @@ import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
type InlinePlayState = { key: string; nonce: number } | null
@ -18,14 +23,11 @@ type Props = {
rows: RecordJob[]
isLoading?: boolean
isSmall: boolean
teaserPlayback: 'still' | 'hover' | 'all'
teaserAudio?: boolean
hoverTeaserKey?: string | null
blurPreviews?: boolean
durations: Record<string, number>
teaserKey: string | null
inlinePlay: InlinePlayState
teaserState: FinishedDownloadsTeaserState
deletingKeys: Set<string>
keepingKeys: Set<string>
@ -524,12 +526,9 @@ export default function FinishedDownloadsCardsView({
rows,
isSmall,
isLoading,
teaserPlayback,
teaserAudio,
hoverTeaserKey,
blurPreviews,
durations,
teaserKey,
teaserState,
inlinePlay,
deletingKeys,
keepingKeys,
@ -704,10 +703,12 @@ export default function FinishedDownloadsCardsView({
const realInlineActive = inlinePlay?.key === k
const inlineActive = opts?.disableInline ? false : realInlineActive
const allowSound =
!opts?.forceStill &&
Boolean(teaserAudio) &&
(inlineActive || hoverTeaserKey === k)
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
inlineActive,
disabled: Boolean(opts?.forceStill),
})
const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
@ -717,15 +718,13 @@ export default function FinishedDownloadsCardsView({
// ✅ Im Mobile-Stack soll nur die Top-Card Teaser-Video bekommen.
// Untere Karten zeigen immer nur Bild (Still), selbst wenn teaserKey mal matcht.
const allowTeaserAnimation =
opts?.forceStill
? false
: opts?.mobileStackTopOnlyVideo
? (teaserPlayback === 'still' ? false : true)
: (teaserPlayback === 'all'
? true
: teaserPlayback === 'hover'
? teaserKey === k
: false)
opts?.mobileStackTopOnlyVideo
? teaserState.mode !== 'still'
: shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: Boolean(opts?.forceStill),
})
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)

View File

@ -14,6 +14,12 @@ import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import PreviewScrubber from './PreviewScrubber'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
shouldObserveTeasers,
} from './teaserPlayback'
type ModelFlags = {
favorite?: boolean
@ -35,10 +41,7 @@ type Props = {
isLoading?: boolean
blurPreviews?: boolean
durations: Record<string, number>
teaserPlayback: 'still' | 'hover' | 'all'
teaserAudio?: boolean
hoverTeaserKey?: string | null
teaserKey: string | null
teaserState: FinishedDownloadsTeaserState
handleDuration: (job: RecordJob, seconds: number) => void
@ -150,10 +153,7 @@ export default function FinishedDownloadsGalleryView({
isLoading,
blurPreviews,
durations,
teaserPlayback,
teaserAudio,
hoverTeaserKey,
teaserKey,
teaserState,
handleDuration,
handleScrubberClickIndex,
@ -189,18 +189,18 @@ export default function FinishedDownloadsGalleryView({
enqueueToggleHot,
}: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
const observeTeasers = shouldObserveTeasers(teaserState.mode)
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback(
(key: string) => (el: HTMLDivElement | null) => {
if (!shouldObserveTeasers) {
if (!observeTeasers) {
registerTeaserHost(key)(null)
return
}
registerTeaserHost(key)(el)
},
[registerTeaserHost, shouldObserveTeasers]
[registerTeaserHost, observeTeasers]
)
const parseTags = (raw?: string): string[] => {
@ -276,7 +276,10 @@ export default function FinishedDownloadsGalleryView({
const k = keyFor(j)
// Sound nur bei Hover auf genau diesem Teaser
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = !allowSound
const model = modelNameFromOutput(j.output)
@ -490,15 +493,11 @@ export default function FinishedDownloadsGalleryView({
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={
hideTeaserUnderOverlay
? false
: teaserPlayback === 'all'
? true
: teaserPlayback === 'hover'
? teaserKey === k
: false
}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: hideTeaserUnderOverlay,
})}
animatedMode="teaser"
animatedTrigger="always"
muted={previewMuted}

View File

@ -5,12 +5,16 @@
import * as React from 'react'
import Table, { type Column, type SortState } from './Table'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
type SortMode =
| 'completed_desc'
@ -39,11 +43,8 @@ type Props = {
// teaser/preview
canHover: boolean
teaserAudio?: boolean
hoverTeaserKey: string | null
teaserState: FinishedDownloadsTeaserState
setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>>
teaserPlayback?: 'still' | 'hover' | 'all'
teaserKey: string | null
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
@ -95,11 +96,8 @@ export default function FinishedDownloadsTableView({
durations,
canHover,
teaserAudio,
hoverTeaserKey,
teaserState,
setHoverTeaserKey,
teaserPlayback = 'hover',
teaserKey,
registerTeaserHost,
handleDuration,
handleResolution,
@ -225,7 +223,10 @@ export default function FinishedDownloadsTableView({
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = !allowSound
const spriteInfo = previewSpriteInfoOf(j)
const scrubActiveIndex = scrubActiveByKey[k]
@ -268,7 +269,10 @@ export default function FinishedDownloadsTableView({
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
})}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
@ -474,16 +478,13 @@ export default function FinishedDownloadsTableView({
keyFor,
baseName,
durations,
teaserAudio,
hoverTeaserKey,
registerTeaserHost,
canHover,
setHoverTeaserKey,
handleDuration,
handleResolution,
blurPreviews,
teaserPlayback,
teaserKey,
teaserState,
assetNonce,
lower,
modelNameFromOutput,

View File

@ -0,0 +1,69 @@
// frontend\src\components\ui\teaserPlayback.ts
export type TeaserPlaybackMode = 'still' | 'hover' | 'all'
export type FinishedDownloadsTeaserState = {
mode: TeaserPlaybackMode
activeKey: string | null
hoverKey: string | null
audioEnabled: boolean
}
type TeaserAnimationOptions = {
state: FinishedDownloadsTeaserState
itemKey: string
disabled?: boolean
forceAll?: boolean
}
type TeaserAudioOptions = {
state: FinishedDownloadsTeaserState
itemKey: string
inlineActive?: boolean
disabled?: boolean
}
export function shouldObserveTeasers(mode: TeaserPlaybackMode): boolean {
return mode === 'hover' || mode === 'all'
}
export function shouldAnimateTeaser({
state,
itemKey,
disabled = false,
forceAll = false,
}: TeaserAnimationOptions): boolean {
if (disabled) return false
if (forceAll) return true
switch (state.mode) {
case 'all':
return true
case 'hover':
return state.hoverKey === itemKey
case 'still':
default:
return false
}
}
export function shouldEnableTeaserAudio({
state,
itemKey,
inlineActive = false,
disabled = false,
}: TeaserAudioOptions): boolean {
if (disabled) return false
if (!state.audioEnabled) return false
// Inline immer bevorzugen
if (inlineActive) return true
// Desktop-Hover
if (state.hoverKey === itemKey) return true
// Mobile / aktiver Teaser im Viewport
if (state.activeKey === itemKey) return true
return false
}