add paste and copy subtitle options

This commit is contained in:
Pas 2025-11-02 21:54:45 -07:00
parent bef85aa741
commit 627d66eced
3 changed files with 125 additions and 25 deletions

View file

@ -736,6 +736,8 @@
}, },
"subtitles": { "subtitles": {
"customChoice": "Drop or upload file", "customChoice": "Drop or upload file",
"pasteChoice": "Paste subtitle data",
"doubleClickToCopy": "Double click to copy subtitle data",
"customizeLabel": "Customize", "customizeLabel": "Customize",
"previewLabel": "Subtitle preview:", "previewLabel": "Subtitle preview:",
"offChoice": "Off", "offChoice": "Off",

View file

@ -40,9 +40,11 @@ export function CaptionOption(props: {
subtitleSource?: string; subtitleSource?: string;
subtitleEncoding?: string; subtitleEncoding?: string;
isHearingImpaired?: boolean; isHearingImpaired?: boolean;
onDoubleClick?: () => void;
}) { }) {
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const tooltipTimeoutRef = useRef<NodeJS.Timeout | null>(null); const tooltipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { t } = useTranslation();
const tooltipContent = useMemo(() => { const tooltipContent = useMemo(() => {
if (!props.subtitleUrl && !props.subtitleSource) return null; if (!props.subtitleUrl && !props.subtitleSource) return null;
@ -107,6 +109,7 @@ export function CaptionOption(props: {
loading={props.loading} loading={props.loading}
error={props.error} error={props.error}
onClick={props.onClick} onClick={props.onClick}
onDoubleClick={props.onDoubleClick}
> >
<span <span
data-active-link={props.selected ? true : undefined} data-active-link={props.selected ? true : undefined}
@ -143,8 +146,13 @@ export function CaptionOption(props: {
</span> </span>
</SelectableLink> </SelectableLink>
{tooltipContent && showTooltip && ( {tooltipContent && showTooltip && (
<div className="absolute z-50 left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-black/80 text-white/80 text-xs rounded-lg backdrop-blur-sm w-60 break-all whitespace-pre-line"> <div className="flex flex-col absolute z-50 left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-black/80 text-white/80 text-xs rounded-lg backdrop-blur-sm w-60 break-all whitespace-pre-line">
{tooltipContent} {tooltipContent}
{props.onDoubleClick && (
<span className="text-white/50 text-xs">
{t("player.menus.subtitles.doubleClickToCopy")}
</span>
)}
</div> </div>
)} )}
</div> </div>
@ -219,6 +227,63 @@ export function CustomCaptionOption() {
); );
} }
export function PasteCaptionOption() {
const { t } = useTranslation();
const setCaption = usePlayerStore((s) => s.setCaption);
const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePaste = async () => {
setIsLoading(true);
setError(null);
try {
const clipboardText = await navigator.clipboard.readText();
const parsedData = JSON.parse(clipboardText);
// Validate the structure
if (!parsedData.id || !parsedData.url || !parsedData.language) {
throw new Error("Invalid subtitle data format");
}
// Check for CORS restrictions
if (parsedData.hasCorsRestrictions) {
throw new Error("Protected subtitle url, cannot be used");
}
// Fetch the subtitle content
const response = await fetch(parsedData.url);
if (!response.ok) {
throw new Error(`Failed to fetch subtitle: ${response.status}`);
}
const subtitleText = await response.text();
// Convert to SRT format
const converted = convert(subtitleText, "srt");
setCaption({
language: parsedData.language,
srtData: converted,
id: parsedData.id,
});
setCustomSubs();
} catch (err) {
console.error("Failed to paste subtitle:", err);
setError(err instanceof Error ? err.message : "Failed to paste subtitle");
} finally {
setIsLoading(false);
}
};
return (
<CaptionOption onClick={handlePaste} loading={isLoading} error={error}>
{t("player.menus.subtitles.pasteChoice")}
</CaptionOption>
);
}
export function CaptionsView({ export function CaptionsView({
id, id,
backLink, backLink,
@ -315,28 +380,54 @@ export function CaptionsView({
// Render subtitle option // Render subtitle option
const renderSubtitleOption = ( const renderSubtitleOption = (
v: CaptionListItem & { languageName: string }, v: CaptionListItem & { languageName: string },
) => ( ) => {
<CaptionOption const handleDoubleClick = async () => {
key={v.id} const copyData = {
countryCode={v.language} id: v.id,
selected={v.id === selectedCaptionId} url: v.url,
loading={v.id === currentlyDownloading && downloadReq.loading} language: v.language,
error={ type: v.type,
v.id === currentlyDownloading && downloadReq.error hasCorsRestrictions: v.needsProxy,
? downloadReq.error.toString() opensubtitles: v.opensubtitles,
: undefined display: v.display,
media: v.media,
isHearingImpaired: v.isHearingImpaired,
source: v.source,
encoding: v.encoding,
};
try {
await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2));
// Could add a toast notification here if needed
} catch (err) {
console.error("Failed to copy subtitle data:", err);
} }
onClick={() => startDownload(v.id)} };
flag
subtitleUrl={v.url} return (
subtitleType={v.type} <CaptionOption
subtitleSource={v.source} key={v.id}
subtitleEncoding={v.encoding} countryCode={v.language}
isHearingImpaired={v.isHearingImpaired} selected={v.id === selectedCaptionId}
> loading={v.id === currentlyDownloading && downloadReq.loading}
{v.languageName} error={
</CaptionOption> v.id === currentlyDownloading && downloadReq.error
); ? downloadReq.error.toString()
: undefined
}
onClick={() => startDownload(v.id)}
onDoubleClick={handleDoubleClick}
flag
subtitleUrl={v.url}
subtitleType={v.type}
subtitleSource={v.source}
subtitleEncoding={v.encoding}
isHearingImpaired={v.isHearingImpaired}
>
{v.languageName}
</CaptionOption>
);
};
return ( return (
<> <>
@ -431,11 +522,14 @@ export function CaptionsView({
{/* Custom upload option */} {/* Custom upload option */}
<CustomCaptionOption /> <CustomCaptionOption />
{/* Paste subtitle option */}
<PasteCaptionOption />
<div className="h-1" />
{/* Search input */} {/* Search input */}
{(sourceCaptions.length || externalCaptions.length) > 0 && ( {(sourceCaptions.length || externalCaptions.length) > 0 && (
<div className="mt-3"> <Input value={searchQuery} onInput={setSearchQuery} />
<Input value={searchQuery} onInput={setSearchQuery} />
</div>
)} )}
{/* No subtitles available message */} {/* No subtitles available message */}

View file

@ -80,6 +80,7 @@ export function Link(props: {
clickable?: boolean; clickable?: boolean;
active?: boolean; active?: boolean;
onClick?: () => void; onClick?: () => void;
onDoubleClick?: () => void;
children?: ReactNode; children?: ReactNode;
className?: string; className?: string;
box?: boolean; box?: boolean;
@ -126,6 +127,7 @@ export function Link(props: {
className={classes} className={classes}
style={props.box ? {} : styles} style={props.box ? {} : styles}
onClick={props.onClick} onClick={props.onClick}
onDoubleClick={props.onDoubleClick}
data-active-link={props.active ? true : undefined} data-active-link={props.active ? true : undefined}
disabled={props.disabled} disabled={props.disabled}
> >
@ -162,6 +164,7 @@ export function SelectableLink(props: {
selected?: boolean; selected?: boolean;
loading?: boolean; loading?: boolean;
onClick?: () => void; onClick?: () => void;
onDoubleClick?: () => void;
children?: ReactNode; children?: ReactNode;
disabled?: boolean; disabled?: boolean;
error?: ReactNode; error?: ReactNode;
@ -187,6 +190,7 @@ export function SelectableLink(props: {
return ( return (
<Link <Link
onClick={props.onClick} onClick={props.onClick}
onDoubleClick={props.onDoubleClick}
clickable={!props.disabled} clickable={!props.disabled}
rightSide={rightContent} rightSide={rightContent}
box={props.box} box={props.box}