mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 05:32:23 +00:00
add paste and copy subtitle options
This commit is contained in:
parent
bef85aa741
commit
627d66eced
3 changed files with 125 additions and 25 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue