mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
A video's audio stream can be dropped the same way its video stream could already be: a new REMOVE_AUDIO audio target maps only the video stream with -an, needs a video container, and is refused for audio files. Output verification insists the result has no audio stream. Removing both streams is refused by the validator, the API, resolve_output, and the modal: choosing Remove for one stream resets the other to Keep and withdraws the other's Remove option. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
668 lines
26 KiB
JavaScript
668 lines
26 KiB
JavaScript
import React, {useEffect, useRef, useState} from 'react';
|
|
import {cancelJob, fetchVideoDownloadDefaults, getJob, transcodeVideo, updateVideo} from '../api';
|
|
import {useWROLMode} from '../hooks/customHooks';
|
|
import {
|
|
Button, Confirm, Group, Icon, Menu, Modal, Progress, Select, Stack, Status, Text, Textarea, TextInput, Toggle,
|
|
toast,
|
|
} from './ui';
|
|
import {
|
|
audioContainerForCodec,
|
|
TRANSCODE_COPY,
|
|
TRANSCODE_REMOVE_AUDIO,
|
|
TRANSCODE_REMOVE_VIDEO,
|
|
transcodeAudioCodecOptions,
|
|
transcodeAudioContainerOptions,
|
|
transcodeContainerOptions,
|
|
transcodeVideoCodecOptions,
|
|
} from './Vars';
|
|
|
|
const JOB_POLL_MS = 2000;
|
|
// Consecutive failed polls before a Job is given up as lost (the API restarted and forgot it).
|
|
const JOB_POLL_MAX_FAILURES = 5;
|
|
export const FINISHED_STATUSES = ['complete', 'failed', 'cancelled'];
|
|
// ffprobe calls an embedded thumbnail a video stream.
|
|
const THUMBNAIL_CODECS = ['mjpeg', 'png', 'bmp', 'gif'];
|
|
|
|
export const isJobFinished = (job) => !!job && FINISHED_STATUSES.includes(job.status);
|
|
|
|
/** The codec names of a Video's real streams, from the `codec_names`/`codec_types` the API sends. */
|
|
export function currentCodecs(video) {
|
|
const names = video?.codec_names || [];
|
|
const types = video?.codec_types || [];
|
|
const video_ = names.filter((name, i) => types[i] === 'video' && !THUMBNAIL_CODECS.includes(name));
|
|
const audio = names.filter((name, i) => types[i] === 'audio');
|
|
return {video: video_, audio};
|
|
}
|
|
|
|
/**
|
|
* The transcode target implied by the user's ordered codec preferences: the first preferred codec
|
|
* that ffmpeg can produce (the same rule the downloader uses). `copy` when none can be.
|
|
*/
|
|
export function preferredTarget(preferences, options) {
|
|
const producible = options.map(i => i.value).filter(i => i !== TRANSCODE_COPY);
|
|
for (const codec of preferences || []) {
|
|
if (producible.includes(codec)) {
|
|
return codec;
|
|
}
|
|
}
|
|
return TRANSCODE_COPY;
|
|
}
|
|
|
|
/** The container of a video (or audio) file, when it is one ffmpeg can write for us. */
|
|
export function currentContainer(video) {
|
|
const suffix = String(video?.video_path || '').split('.').pop().toLowerCase();
|
|
const known = [...transcodeContainerOptions, ...transcodeAudioContainerOptions];
|
|
return known.some(i => i.value === suffix) ? suffix : null;
|
|
}
|
|
|
|
/**
|
|
* The container to offer for the chosen codecs: audio-only output gets the natural container of
|
|
* the audio codec that will be in it (the target, or the source's when copied); output with video
|
|
* keeps the file's own container when possible.
|
|
*/
|
|
export function containerFor({audioOutput, audioCodec, video}) {
|
|
if (audioOutput) {
|
|
const codec = audioCodec !== TRANSCODE_COPY ? audioCodec : currentCodecs(video).audio[0];
|
|
return audioContainerForCodec(codec);
|
|
}
|
|
const current = currentContainer(video);
|
|
return transcodeContainerOptions.some(i => i.value === current) ? current : 'mp4';
|
|
}
|
|
|
|
/**
|
|
* The video codec choices: Remove video is withdrawn once the audio is being removed (nothing
|
|
* would remain).
|
|
*/
|
|
export function videoCodecOptionsFor({removeAudio}) {
|
|
return removeAudio ? transcodeVideoCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_VIDEO)
|
|
: transcodeVideoCodecOptions;
|
|
}
|
|
|
|
/**
|
|
* The audio codec choices: Remove audio is only for a video whose video stream is kept; an audio
|
|
* file has nothing else, and removing the video already chose the audio as the result.
|
|
*/
|
|
export function audioCodecOptionsFor({audioOnly, removeVideo}) {
|
|
return (audioOnly || removeVideo) ? transcodeAudioCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_AUDIO)
|
|
: transcodeAudioCodecOptions;
|
|
}
|
|
|
|
/**
|
|
* The codec pair after one select changes. Both streams can never be removed (nothing would
|
|
* remain): choosing Remove for one stream puts the other back to Keep.
|
|
*/
|
|
export function chooseCodecs({videoCodec, audioCodec}, {video, audio}) {
|
|
let nextVideo = video !== undefined ? (video || TRANSCODE_COPY) : videoCodec;
|
|
let nextAudio = audio !== undefined ? (audio || TRANSCODE_COPY) : audioCodec;
|
|
if (nextVideo === TRANSCODE_REMOVE_VIDEO && nextAudio === TRANSCODE_REMOVE_AUDIO) {
|
|
if (video !== undefined) {
|
|
nextAudio = TRANSCODE_COPY;
|
|
} else {
|
|
nextVideo = TRANSCODE_COPY;
|
|
}
|
|
}
|
|
return {videoCodec: nextVideo, audioCodec: nextAudio};
|
|
}
|
|
|
|
const statusKind = (status) => {
|
|
if (status === 'complete') return 'complete';
|
|
if (status === 'running') return 'active';
|
|
if (status === 'failed' || status === 'cancelled') return 'failed';
|
|
return 'pending';
|
|
}
|
|
|
|
/**
|
|
* Poll a Job until it finishes; `onFinished(job)` is called once. A failed request is retried;
|
|
* a 404 (the API restarted and forgot its Jobs) or `JOB_POLL_MAX_FAILURES` failures in a row stop
|
|
* the polling and call `onLost(error)` instead.
|
|
*/
|
|
export function useJob(jobId, onFinished, onLost) {
|
|
const [job, setJob] = useState(null);
|
|
const onFinishedRef = useRef(onFinished);
|
|
const onLostRef = useRef(onLost);
|
|
onFinishedRef.current = onFinished;
|
|
onLostRef.current = onLost;
|
|
|
|
useEffect(() => {
|
|
setJob(null);
|
|
if (!jobId) {
|
|
return;
|
|
}
|
|
let stopped = false;
|
|
let timer = null;
|
|
let failures = 0;
|
|
|
|
const poll = async () => {
|
|
let latest = null;
|
|
try {
|
|
latest = await getJob(jobId);
|
|
failures = 0;
|
|
} catch (e) {
|
|
console.error(e);
|
|
failures += 1;
|
|
if (!stopped && (e?.status === 404 || failures >= JOB_POLL_MAX_FAILURES)) {
|
|
if (onLostRef.current) onLostRef.current(e);
|
|
return;
|
|
}
|
|
}
|
|
if (stopped) return;
|
|
if (latest) {
|
|
setJob(latest);
|
|
if (isJobFinished(latest)) {
|
|
if (onFinishedRef.current) onFinishedRef.current(latest);
|
|
return;
|
|
}
|
|
}
|
|
timer = window.setTimeout(poll, JOB_POLL_MS);
|
|
};
|
|
|
|
poll();
|
|
return () => {
|
|
stopped = true;
|
|
clearTimeout(timer);
|
|
};
|
|
}, [jobId]);
|
|
|
|
const cancel = async () => {
|
|
const latest = await cancelJob(jobId);
|
|
setJob(latest);
|
|
return latest;
|
|
};
|
|
|
|
return {job, cancel};
|
|
}
|
|
|
|
/** Progress, status and log tail of one Job. */
|
|
export function JobProgress({job, onCancel}) {
|
|
const [cancelling, setCancelling] = useState(false);
|
|
|
|
const handleCancel = async () => {
|
|
setCancelling(true);
|
|
try {
|
|
await onCancel();
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setCancelling(false);
|
|
}
|
|
};
|
|
|
|
if (!job) {
|
|
return <Progress indeterminate showPercent={false} label='Starting...'/>;
|
|
}
|
|
|
|
const finished = isJobFinished(job);
|
|
const percent = job.progress ?? 0;
|
|
const logTail = (job.log || []).slice(-8);
|
|
|
|
return <Stack gap='sm'>
|
|
<Group justify='space-between' wrap='wrap'>
|
|
<Status kind={statusKind(job.status)}>{job.status}</Status>
|
|
{!finished && onCancel && <Button
|
|
role='danger'
|
|
size='small'
|
|
icon='stop'
|
|
onClick={handleCancel}
|
|
loading={cancelling}
|
|
disabled={cancelling || job.cancel_requested}
|
|
>
|
|
{job.cancel_requested ? 'Cancelling...' : 'Cancel'}
|
|
</Button>}
|
|
</Group>
|
|
<Progress
|
|
percent={percent}
|
|
indeterminate={!finished && job.progress === null}
|
|
color={job.status === 'failed' ? 'red' : job.status === 'complete' ? 'green' : 'blue'}
|
|
/>
|
|
{job.error && <Text c='var(--danger)' size='sm'>{job.error}</Text>}
|
|
{logTail.length > 0 && <pre
|
|
data-testid='job-log'
|
|
style={{
|
|
margin: 0,
|
|
maxHeight: '10em',
|
|
overflow: 'auto',
|
|
fontSize: '0.75em',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-all',
|
|
background: 'var(--surface-2, var(--surface))',
|
|
padding: '0.5em',
|
|
borderRadius: 'var(--radius, 4px)',
|
|
}}
|
|
>{logTail.join('\n')}</pre>}
|
|
</Stack>
|
|
}
|
|
|
|
/**
|
|
* Choose target codecs for one Video and queue the transcode Job (`onQueued(jobId)`), or show the
|
|
* given `job`. The selects start at the user's preferred codecs from the Videos settings.
|
|
*/
|
|
export function TranscodeModal({
|
|
open, onClose, fileGroupId, video, audioOnly = false, job, jobId, onQueued,
|
|
onCancelJob,
|
|
}) {
|
|
const wrolModeEnabled = useWROLMode();
|
|
const [videoCodec, setVideoCodec] = useState(TRANSCODE_COPY);
|
|
const [audioCodec, setAudioCodec] = useState(TRANSCODE_COPY);
|
|
const [container, setContainer] = useState('mp4');
|
|
const [fragmented, setFragmented] = useState(false);
|
|
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const current = currentCodecs(video);
|
|
const removeVideo = videoCodec === TRANSCODE_REMOVE_VIDEO;
|
|
const removeAudio = !audioOnly && !removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO;
|
|
// An audio file has no video stream to keep or remove; removing the video makes an audio file.
|
|
const audioOutput = audioOnly || removeVideo;
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
setDefaultsLoaded(false);
|
|
setFragmented(false);
|
|
let stale = false;
|
|
const load = async () => {
|
|
let nextVideo = TRANSCODE_COPY;
|
|
let nextAudio = TRANSCODE_COPY;
|
|
try {
|
|
const defaults = await fetchVideoDownloadDefaults();
|
|
if (stale) return;
|
|
nextVideo = audioOnly ? TRANSCODE_COPY
|
|
: preferredTarget(defaults?.video_codecs, transcodeVideoCodecOptions);
|
|
nextAudio = preferredTarget(defaults?.audio_codecs, transcodeAudioCodecOptions);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
if (!stale) {
|
|
setVideoCodec(nextVideo);
|
|
setAudioCodec(nextAudio);
|
|
setContainer(containerFor({audioOutput: audioOnly, audioCodec: nextAudio, video}));
|
|
setDefaultsLoaded(true);
|
|
}
|
|
}
|
|
};
|
|
load();
|
|
return () => {
|
|
stale = true;
|
|
};
|
|
}, [open]);
|
|
|
|
// `chooseCodecs` keeps Remove video and Remove audio from ever being chosen together; the
|
|
// other select also withdraws its Remove option (see `videoCodecOptionsFor`/`audioCodecOptionsFor`).
|
|
const applyCodecs = (change) => {
|
|
const next = chooseCodecs({videoCodec, audioCodec}, change);
|
|
setVideoCodec(next.videoCodec);
|
|
setAudioCodec(next.audioCodec);
|
|
setContainer(containerFor({
|
|
audioOutput: audioOnly || next.videoCodec === TRANSCODE_REMOVE_VIDEO, audioCodec: next.audioCodec, video,
|
|
}));
|
|
};
|
|
const changeVideoCodec = (value) => applyCodecs({video: value});
|
|
const changeAudioCodec = (value) => applyCodecs({audio: value});
|
|
|
|
const remuxOnly = !removeVideo && !removeAudio && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY;
|
|
const sameContainer = container === currentContainer(video);
|
|
const containerOptions = audioOutput ? transcodeAudioContainerOptions : transcodeContainerOptions;
|
|
// Fragments are an mp4 concept (m4a is an mp4).
|
|
const canFragment = container === 'mp4' || container === 'm4a';
|
|
|
|
const handleStart = async () => {
|
|
setSubmitting(true);
|
|
try {
|
|
const id = await transcodeVideo(fileGroupId, {
|
|
video_codec: videoCodec === TRANSCODE_COPY ? null : videoCodec,
|
|
audio_codec: audioCodec === TRANSCODE_COPY || (removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO)
|
|
? null : audioCodec,
|
|
container,
|
|
fragmented: canFragment && fragmented,
|
|
});
|
|
onQueued(id);
|
|
} catch (e) {
|
|
// transcodeVideo already toasted.
|
|
console.error(e);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const describe = (codecs) => codecs.length ? codecs.join(', ') : 'unknown';
|
|
const showJob = !!jobId;
|
|
|
|
let hint = null;
|
|
if (defaultsLoaded && removeVideo) {
|
|
hint = `The video stream is removed; the result is an audio file (.${container}). `
|
|
+ (audioCodec === TRANSCODE_COPY ? 'The audio is copied without re-encoding.' : `The audio is converted to ${audioCodec}.`);
|
|
} else if (defaultsLoaded && removeAudio) {
|
|
hint = `The audio stream is removed; the result is a silent video (.${container}). `
|
|
+ (videoCodec === TRANSCODE_COPY ? 'The video is copied without re-encoding.' : `The video is converted to ${videoCodec}.`);
|
|
} else if (defaultsLoaded && remuxOnly) {
|
|
const kept = audioOnly ? 'The audio is kept' : 'Both streams are kept';
|
|
if (sameContainer && canFragment && fragmented) {
|
|
hint = `${kept}: the file is rewritten as-is in fragments (a quick remux, no quality loss).`;
|
|
} else if (sameContainer) {
|
|
hint = `${kept}: the file is rewritten as-is` + (canFragment
|
|
? ' with fast start (moves the index to the front for quicker playback start).' : '.');
|
|
} else {
|
|
hint = `${kept}: only the container changes to ${container} (a quick remux, no quality loss).`;
|
|
}
|
|
}
|
|
const buttonLabel = removeVideo ? 'Extract Audio' : removeAudio ? 'Remove Audio' : remuxOnly ? 'Remux' : 'Transcode';
|
|
|
|
return <Modal open={open} onClose={onClose} size='small'>
|
|
<Modal.Header>{audioOnly ? 'Transcode Audio' : 'Transcode Video'}</Modal.Header>
|
|
<Modal.Content>
|
|
<Stack gap='md'>
|
|
<Text size='sm' c='var(--muted)'>
|
|
{audioOnly
|
|
? `Current codec: audio ${describe(current.audio)}. `
|
|
: `Current codecs: video ${describe(current.video)}, audio ${describe(current.audio)}. `}
|
|
Streams set to Keep are copied without re-encoding. Transcoding is slow and slightly
|
|
reduces quality; the original file is replaced when it finishes.
|
|
</Text>
|
|
{!showJob && <>
|
|
{!audioOnly && <Select
|
|
label='Video codec'
|
|
data={videoCodecOptionsFor({removeAudio})}
|
|
value={videoCodec}
|
|
onChange={changeVideoCodec}
|
|
disabled={!defaultsLoaded}
|
|
allowDeselect={false}
|
|
/>}
|
|
<Select
|
|
label='Audio codec'
|
|
data={audioCodecOptionsFor({audioOnly, removeVideo})}
|
|
value={audioCodec}
|
|
onChange={changeAudioCodec}
|
|
disabled={!defaultsLoaded}
|
|
allowDeselect={false}
|
|
/>
|
|
<Select
|
|
label='Container'
|
|
data={containerOptions}
|
|
value={container}
|
|
onChange={value => setContainer(value || containerOptions[0].value)}
|
|
disabled={!defaultsLoaded}
|
|
allowDeselect={false}
|
|
/>
|
|
{canFragment && <Toggle
|
|
label={`Fragmented (for very long ${audioOutput ? 'audio' : 'videos'})`}
|
|
checked={fragmented}
|
|
onChange={e => setFragmented(e.currentTarget.checked)}
|
|
disabled={!defaultsLoaded}
|
|
info='Splits the index into fragments instead of one block at the front. A video many
|
|
hours long then starts and seeks quickly on phones, which otherwise must download the
|
|
whole index first.'
|
|
/>}
|
|
{hint && <Text size='sm' c='var(--muted)'>{hint}</Text>}
|
|
</>}
|
|
{showJob && <JobProgress job={job} onCancel={onCancelJob}/>}
|
|
</Stack>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button role='cancel' onClick={onClose}>{showJob ? 'Close' : 'Cancel'}</Button>
|
|
{!showJob && <Button
|
|
role='primary'
|
|
icon='film'
|
|
onClick={handleStart}
|
|
loading={submitting}
|
|
disabled={submitting || !defaultsLoaded || !!wrolModeEnabled}
|
|
>
|
|
{buttonLabel}
|
|
</Button>}
|
|
</Modal.Actions>
|
|
</Modal>
|
|
}
|
|
|
|
/**
|
|
* Edit a Video's details. The change is written to the Video's info json, so it survives a
|
|
* refresh; a re-download of the Video's metadata will overwrite it.
|
|
*/
|
|
export function EditVideoModal({open, onClose, fileGroupId, videoFile, description: currentDescription, onSaved}) {
|
|
const wrolModeEnabled = useWROLMode();
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
// The description last copied into the textarea; the user has typed when the field differs.
|
|
const syncedDescription = useRef('');
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
// Start from the current details on every open, discarding an abandoned edit.
|
|
setTitle(videoFile?.title || '');
|
|
setDescription(currentDescription || '');
|
|
syncedDescription.current = currentDescription || '';
|
|
}
|
|
}, [open, videoFile?.title]);
|
|
|
|
useEffect(() => {
|
|
// The description may still be loading when the dialog opens (null), and it is refetched
|
|
// after a save. Adopt what arrives only while the user has not typed, so a draft written
|
|
// over the placeholder is never wiped by the empty or failed value that follows it.
|
|
if (open && currentDescription != null && description === syncedDescription.current) {
|
|
setDescription(currentDescription);
|
|
syncedDescription.current = currentDescription;
|
|
}
|
|
}, [currentDescription]);
|
|
|
|
const trimmed = title.trim();
|
|
const titleChanged = trimmed !== (videoFile?.title || '');
|
|
const descriptionChanged = description !== (currentDescription || '');
|
|
const unchanged = !titleChanged && !descriptionChanged;
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
// Send only what changed, so an untouched field is never rewritten.
|
|
const changes = {};
|
|
if (titleChanged) changes.title = trimmed;
|
|
if (descriptionChanged) changes.description = description;
|
|
const updated = await updateVideo(fileGroupId, changes);
|
|
toast({type: 'success', title: 'Saved', description: 'The video was updated.', time: 3000});
|
|
if (onSaved) await onSaved(updated);
|
|
onClose();
|
|
} catch (e) {
|
|
// updateVideo already toasted.
|
|
console.error(e);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return <Modal open={open} onClose={onClose} size='small'>
|
|
<Modal.Header>Edit Video</Modal.Header>
|
|
<Modal.Content>
|
|
{/* No submit-on-Enter: Enter belongs to the description's new lines. */}
|
|
<form onSubmit={e => e.preventDefault()}>
|
|
<TextInput
|
|
label='Title'
|
|
value={title}
|
|
onChange={e => setTitle(e.currentTarget.value)}
|
|
error={!trimmed ? 'A title is required' : null}
|
|
data-autofocus
|
|
/>
|
|
<Textarea
|
|
label='Description'
|
|
value={description}
|
|
onChange={e => setDescription(e.currentTarget.value)}
|
|
autosize
|
|
minRows={4}
|
|
maxRows={14}
|
|
mt='sm'
|
|
/>
|
|
<Text size='sm' c='var(--muted)' mt='sm'>
|
|
Saved to the video's info json. Refreshing the video from its source will replace it.
|
|
</Text>
|
|
{wrolModeEnabled && <Text size='sm' c='var(--danger)' mt='sm'>
|
|
Videos cannot be edited while WROL Mode is enabled.
|
|
</Text>}
|
|
</form>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button role='cancel' onClick={onClose}>Cancel</Button>
|
|
<Button
|
|
role='save'
|
|
icon='save'
|
|
onClick={handleSave}
|
|
loading={saving}
|
|
disabled={saving || !trimmed || unchanged || !!wrolModeEnabled}
|
|
>
|
|
Save
|
|
</Button>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
}
|
|
|
|
/**
|
|
* The "Edit" dropdown on the Video page: Edit, Refresh, Transcode, Delete. Holds this video's
|
|
* active transcode Job; `onTranscodeComplete` fires whether or not the modal is open.
|
|
*/
|
|
export function VideoEditMenu({
|
|
videoFile, video, description, onRefresh, onTranscodeComplete, onDelete, onSaved,
|
|
initialJobId = null,
|
|
}) {
|
|
const wrolModeEnabled = useWROLMode();
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [transcodeOpen, setTranscodeOpen] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
// `initialJobId`: resume watching a Job already queued for this video (tests use it too).
|
|
const [jobId, setJobId] = useState(initialJobId);
|
|
|
|
const handleJobFinished = (finished) => {
|
|
if (finished.status === 'complete') {
|
|
toast({type: 'success', title: 'Transcode complete', description: finished.description, time: 5000});
|
|
if (onTranscodeComplete) onTranscodeComplete(finished);
|
|
} else if (finished.status === 'failed') {
|
|
toast({
|
|
type: 'error', title: 'Transcode failed', time: 10000,
|
|
description: `${finished.description}: ${finished.error || 'unknown error'}. The original file was kept.`,
|
|
});
|
|
} else if (finished.status === 'cancelled') {
|
|
toast({type: 'info', title: 'Transcode cancelled', description: finished.description, time: 5000});
|
|
}
|
|
if (!transcodeOpen) {
|
|
// Nobody is looking at the result; make room for the next transcode.
|
|
setJobId(null);
|
|
}
|
|
};
|
|
const handleJobLost = (error) => {
|
|
toast({
|
|
type: 'warning', title: 'Lost track of the transcode', time: 10000,
|
|
description: 'The job is no longer known (the API may have restarted). Check the file before trying again.',
|
|
});
|
|
setJobId(null);
|
|
setTranscodeOpen(false);
|
|
};
|
|
const {job, cancel} = useJob(jobId, handleJobFinished, handleJobLost);
|
|
|
|
// Another video: its transcodes are its own.
|
|
const firstVideoId = useRef(videoFile?.id);
|
|
useEffect(() => {
|
|
if (videoFile?.id !== firstVideoId.current) {
|
|
firstVideoId.current = videoFile?.id;
|
|
setJobId(null);
|
|
}
|
|
}, [videoFile?.id]);
|
|
|
|
const handleTranscodeClose = () => {
|
|
setTranscodeOpen(false);
|
|
if (isJobFinished(job)) {
|
|
setJobId(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
setDeleting(true);
|
|
try {
|
|
await onDelete();
|
|
setDeleteOpen(false);
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const isVideo = !!videoFile?.mimetype?.startsWith('video/');
|
|
const isAudio = !!videoFile?.mimetype?.startsWith('audio/');
|
|
const transcoding = !!jobId && !isJobFinished(job);
|
|
|
|
return <>
|
|
<Menu position='bottom-start' withinPortal>
|
|
<Menu.Target>
|
|
<Button color='yellow' icon='edit' iconAfter='dropdown' loading={refreshing}>Edit</Button>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<Icon name='edit'/>}
|
|
onClick={() => setEditOpen(true)}
|
|
disabled={!!wrolModeEnabled}
|
|
>
|
|
Edit...
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<Icon name='refresh'/>}
|
|
onClick={handleRefresh}
|
|
disabled={!videoFile?.url || !!wrolModeEnabled || refreshing}
|
|
>
|
|
Refresh
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<Icon name='film'/>}
|
|
onClick={() => setTranscodeOpen(true)}
|
|
disabled={!(isVideo || isAudio) || (!!wrolModeEnabled && !transcoding)}
|
|
>
|
|
{transcoding ? 'Transcoding...' : 'Transcode...'}
|
|
</Menu.Item>
|
|
<Menu.Divider/>
|
|
<Menu.Item
|
|
color='var(--danger)'
|
|
leftSection={<Icon name='trash'/>}
|
|
onClick={() => setDeleteOpen(true)}
|
|
disabled={!onDelete || !!wrolModeEnabled || transcoding}
|
|
>
|
|
Delete...
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
<TranscodeModal
|
|
open={transcodeOpen}
|
|
onClose={handleTranscodeClose}
|
|
fileGroupId={videoFile?.id}
|
|
video={video}
|
|
audioOnly={isAudio}
|
|
job={job}
|
|
jobId={jobId}
|
|
onQueued={setJobId}
|
|
onCancelJob={cancel}
|
|
/>
|
|
<EditVideoModal
|
|
open={editOpen}
|
|
onClose={() => setEditOpen(false)}
|
|
fileGroupId={videoFile?.id}
|
|
videoFile={videoFile}
|
|
description={description}
|
|
onSaved={onSaved}
|
|
/>
|
|
<Confirm
|
|
open={deleteOpen}
|
|
title='Delete video?'
|
|
confirmLabel='Delete'
|
|
destructive
|
|
loading={deleting}
|
|
onConfirm={handleDelete}
|
|
onCancel={() => setDeleteOpen(false)}
|
|
>
|
|
Are you sure you want to delete this video? All files related to this video will be deleted.
|
|
It will not be downloaded again!
|
|
</Confirm>
|
|
</>
|
|
}
|