pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add paste and copy subtitle options

Pas 627d66ec bef85aa7

+125 -25
+2
src/assets/locales/en.json
··· 736 736 }, 737 737 "subtitles": { 738 738 "customChoice": "Drop or upload file", 739 + "pasteChoice": "Paste subtitle data", 740 + "doubleClickToCopy": "Double click to copy subtitle data", 739 741 "customizeLabel": "Customize", 740 742 "previewLabel": "Subtitle preview:", 741 743 "offChoice": "Off",
+119 -25
src/components/player/atoms/settings/CaptionsView.tsx
··· 40 40 subtitleSource?: string; 41 41 subtitleEncoding?: string; 42 42 isHearingImpaired?: boolean; 43 + onDoubleClick?: () => void; 43 44 }) { 44 45 const [showTooltip, setShowTooltip] = useState(false); 45 46 const tooltipTimeoutRef = useRef<NodeJS.Timeout | null>(null); 47 + const { t } = useTranslation(); 46 48 47 49 const tooltipContent = useMemo(() => { 48 50 if (!props.subtitleUrl && !props.subtitleSource) return null; ··· 107 109 loading={props.loading} 108 110 error={props.error} 109 111 onClick={props.onClick} 112 + onDoubleClick={props.onDoubleClick} 110 113 > 111 114 <span 112 115 data-active-link={props.selected ? true : undefined} ··· 143 146 </span> 144 147 </SelectableLink> 145 148 {tooltipContent && showTooltip && ( 146 - <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"> 149 + <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"> 147 150 {tooltipContent} 151 + {props.onDoubleClick && ( 152 + <span className="text-white/50 text-xs"> 153 + {t("player.menus.subtitles.doubleClickToCopy")} 154 + </span> 155 + )} 148 156 </div> 149 157 )} 150 158 </div> ··· 219 227 ); 220 228 } 221 229 230 + export function PasteCaptionOption() { 231 + const { t } = useTranslation(); 232 + const setCaption = usePlayerStore((s) => s.setCaption); 233 + const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs); 234 + const [isLoading, setIsLoading] = useState(false); 235 + const [error, setError] = useState<string | null>(null); 236 + 237 + const handlePaste = async () => { 238 + setIsLoading(true); 239 + setError(null); 240 + 241 + try { 242 + const clipboardText = await navigator.clipboard.readText(); 243 + const parsedData = JSON.parse(clipboardText); 244 + 245 + // Validate the structure 246 + if (!parsedData.id || !parsedData.url || !parsedData.language) { 247 + throw new Error("Invalid subtitle data format"); 248 + } 249 + 250 + // Check for CORS restrictions 251 + if (parsedData.hasCorsRestrictions) { 252 + throw new Error("Protected subtitle url, cannot be used"); 253 + } 254 + 255 + // Fetch the subtitle content 256 + const response = await fetch(parsedData.url); 257 + if (!response.ok) { 258 + throw new Error(`Failed to fetch subtitle: ${response.status}`); 259 + } 260 + 261 + const subtitleText = await response.text(); 262 + 263 + // Convert to SRT format 264 + const converted = convert(subtitleText, "srt"); 265 + 266 + setCaption({ 267 + language: parsedData.language, 268 + srtData: converted, 269 + id: parsedData.id, 270 + }); 271 + setCustomSubs(); 272 + } catch (err) { 273 + console.error("Failed to paste subtitle:", err); 274 + setError(err instanceof Error ? err.message : "Failed to paste subtitle"); 275 + } finally { 276 + setIsLoading(false); 277 + } 278 + }; 279 + 280 + return ( 281 + <CaptionOption onClick={handlePaste} loading={isLoading} error={error}> 282 + {t("player.menus.subtitles.pasteChoice")} 283 + </CaptionOption> 284 + ); 285 + } 286 + 222 287 export function CaptionsView({ 223 288 id, 224 289 backLink, ··· 315 380 // Render subtitle option 316 381 const renderSubtitleOption = ( 317 382 v: CaptionListItem & { languageName: string }, 318 - ) => ( 319 - <CaptionOption 320 - key={v.id} 321 - countryCode={v.language} 322 - selected={v.id === selectedCaptionId} 323 - loading={v.id === currentlyDownloading && downloadReq.loading} 324 - error={ 325 - v.id === currentlyDownloading && downloadReq.error 326 - ? downloadReq.error.toString() 327 - : undefined 383 + ) => { 384 + const handleDoubleClick = async () => { 385 + const copyData = { 386 + id: v.id, 387 + url: v.url, 388 + language: v.language, 389 + type: v.type, 390 + hasCorsRestrictions: v.needsProxy, 391 + opensubtitles: v.opensubtitles, 392 + display: v.display, 393 + media: v.media, 394 + isHearingImpaired: v.isHearingImpaired, 395 + source: v.source, 396 + encoding: v.encoding, 397 + }; 398 + 399 + try { 400 + await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2)); 401 + // Could add a toast notification here if needed 402 + } catch (err) { 403 + console.error("Failed to copy subtitle data:", err); 328 404 } 329 - onClick={() => startDownload(v.id)} 330 - flag 331 - subtitleUrl={v.url} 332 - subtitleType={v.type} 333 - subtitleSource={v.source} 334 - subtitleEncoding={v.encoding} 335 - isHearingImpaired={v.isHearingImpaired} 336 - > 337 - {v.languageName} 338 - </CaptionOption> 339 - ); 405 + }; 406 + 407 + return ( 408 + <CaptionOption 409 + key={v.id} 410 + countryCode={v.language} 411 + selected={v.id === selectedCaptionId} 412 + loading={v.id === currentlyDownloading && downloadReq.loading} 413 + error={ 414 + v.id === currentlyDownloading && downloadReq.error 415 + ? downloadReq.error.toString() 416 + : undefined 417 + } 418 + onClick={() => startDownload(v.id)} 419 + onDoubleClick={handleDoubleClick} 420 + flag 421 + subtitleUrl={v.url} 422 + subtitleType={v.type} 423 + subtitleSource={v.source} 424 + subtitleEncoding={v.encoding} 425 + isHearingImpaired={v.isHearingImpaired} 426 + > 427 + {v.languageName} 428 + </CaptionOption> 429 + ); 430 + }; 340 431 341 432 return ( 342 433 <> ··· 431 522 {/* Custom upload option */} 432 523 <CustomCaptionOption /> 433 524 525 + {/* Paste subtitle option */} 526 + <PasteCaptionOption /> 527 + 528 + <div className="h-1" /> 529 + 434 530 {/* Search input */} 435 531 {(sourceCaptions.length || externalCaptions.length) > 0 && ( 436 - <div className="mt-3"> 437 - <Input value={searchQuery} onInput={setSearchQuery} /> 438 - </div> 532 + <Input value={searchQuery} onInput={setSearchQuery} /> 439 533 )} 440 534 441 535 {/* No subtitles available message */}
+4
src/components/player/internals/ContextMenu/Links.tsx
··· 80 80 clickable?: boolean; 81 81 active?: boolean; 82 82 onClick?: () => void; 83 + onDoubleClick?: () => void; 83 84 children?: ReactNode; 84 85 className?: string; 85 86 box?: boolean; ··· 126 127 className={classes} 127 128 style={props.box ? {} : styles} 128 129 onClick={props.onClick} 130 + onDoubleClick={props.onDoubleClick} 129 131 data-active-link={props.active ? true : undefined} 130 132 disabled={props.disabled} 131 133 > ··· 162 164 selected?: boolean; 163 165 loading?: boolean; 164 166 onClick?: () => void; 167 + onDoubleClick?: () => void; 165 168 children?: ReactNode; 166 169 disabled?: boolean; 167 170 error?: ReactNode; ··· 187 190 return ( 188 191 <Link 189 192 onClick={props.onClick} 193 + onDoubleClick={props.onDoubleClick} 190 194 clickable={!props.disabled} 191 195 rightSide={rightContent} 192 196 box={props.box}