Add stream/magnet link copy and download-from-context-menu

Adds a context menu on the player overlay and source rows to copy a
stream's direct/magnet link or trigger an offline download, backed
by a new stream_magnet_link command and a torrent-download resolver
that reuses (or starts) the torrent server on demand. Also refactors
start_torrent_stream's health-check into ensure_healthy_torrent_base_url
for reuse, and keeps the torrent server alive across stream stops so
downloads can still be resolved, tearing it down only on app exit.
This commit is contained in:
KhooLy 2026-07-15 19:36:03 +03:00
parent dfc1299215
commit 736b99313e
11 changed files with 275 additions and 82 deletions

View file

@ -13,6 +13,9 @@
"core:window:allow-set-always-on-top",
"core:window:allow-current-monitor",
"core:window:allow-available-monitors",
"core:window:allow-set-decorations",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:webview:allow-set-webview-zoom",
"shell:allow-open",
{

View file

@ -135,6 +135,7 @@ async fn run_download(
request_json: String,
playback_url: String,
video_file_name: String,
is_local_source: bool,
cancel: Arc<AtomicBool>,
) {
let temp_path = offline_dir.join(format!("{video_file_name}.part"));
@ -165,9 +166,11 @@ async fn run_download(
emit(downloaded, total, "failed", Some(&message));
};
if let Err(e) = crate::net_guard::ensure_public_host(&playback_url).await {
fail(&offline_dir, resume_from, None, e);
return;
if !is_local_source {
if let Err(e) = crate::net_guard::ensure_public_host(&playback_url).await {
fail(&offline_dir, resume_from, None, e);
return;
}
}
let client = match reqwest::Client::builder()
@ -282,6 +285,7 @@ fn spawn_download(
request_json: String,
playback_url: String,
video_file_name: String,
is_local_source: bool,
) {
let cancel = Arc::new(AtomicBool::new(false));
state
@ -297,10 +301,29 @@ fn spawn_download(
request_json,
playback_url,
video_file_name,
is_local_source,
cancel,
));
}
async fn resolve_download_source(
state: &State<'_, DesktopState>,
plan: &Value,
request_json: &str,
fallback_url: String,
) -> Result<(String, bool), String> {
if plan.get("isTorrent").and_then(Value::as_bool) != Some(true) {
return Ok((fallback_url, false));
}
let stream_json = serde_json::from_str::<Value>(request_json)
.ok()
.and_then(|request| request.get("stream").cloned())
.ok_or_else(|| "offline download request has no stream".to_string())?
.to_string();
let url = crate::resolve_torrent_download_url(state, stream_json).await?;
Ok((url, true))
}
#[tauri::command]
pub async fn enqueue_offline_download(
app: AppHandle,
@ -311,6 +334,8 @@ pub async fn enqueue_offline_download(
Ok(v) => v,
Err(plan_json) => return Ok(Some(plan_json)),
};
let (download_url, is_local_source) =
resolve_download_source(&state, &plan, &request_json, playback_url).await?;
let offline_dir =
resolve_offline_dir(&state).ok_or_else(|| "app data dir is not ready".to_string())?;
fs::create_dir_all(&offline_dir).map_err(|e| e.to_string())?;
@ -335,8 +360,9 @@ pub async fn enqueue_offline_download(
offline_dir,
id,
request_json,
playback_url,
download_url,
video_file_name.clone(),
is_local_source,
);
let mut queued = plan;
@ -371,9 +397,9 @@ pub fn cancel_offline_download(state: State<DesktopState>, id: String) -> Result
}
#[tauri::command]
pub fn resume_offline_download(
pub async fn resume_offline_download(
app: AppHandle,
state: State<DesktopState>,
state: State<'_, DesktopState>,
id: String,
) -> Result<(), String> {
let offline_dir =
@ -383,8 +409,10 @@ pub fn resume_offline_download(
.into_iter()
.find(|e| e.id == id)
.ok_or_else(|| "no download to resume".to_string())?;
let (_, playback_url, video_file_name) = build_plan(&entry.request_json)?;
let (plan, playback_url, video_file_name) = build_plan(&entry.request_json)?;
let request_json = entry.request_json.clone();
let (download_url, is_local_source) =
resolve_download_source(&state, &plan, &request_json, playback_url).await?;
upsert_manifest(
&offline_dir,
ManifestEntry {
@ -399,8 +427,9 @@ pub fn resume_offline_download(
offline_dir,
id,
request_json,
playback_url,
download_url,
video_file_name,
is_local_source,
);
Ok(())
}

View file

@ -156,6 +156,11 @@ impl Default for DesktopState {
static DIAGNOSTIC_MODE: AtomicBool = AtomicBool::new(false);
#[tauri::command]
fn is_linux() -> bool {
cfg!(target_os = "linux")
}
#[tauri::command]
fn debug_log(msg: String) {
if std::env::var_os("FLUXA_DEBUG_LOGS").is_some() {
@ -395,6 +400,39 @@ fn start_torrent_stream_inner(
))
}
#[tauri::command]
fn stream_magnet_link(stream_json: String) -> Option<String> {
FluxaCore::stream_magnet_link_json(&stream_json)
}
async fn ensure_healthy_torrent_base_url(
state: &State<'_, DesktopState>,
data_dir: &std::path::Path,
) -> Option<String> {
let base_url = state.torrent_server_base_url.lock().unwrap().clone()?;
let healthy = tauri::async_runtime::spawn_blocking({
let base_url = base_url.clone();
move || torrent_server_healthy(&base_url)
})
.await
.unwrap_or(false);
if healthy {
return Some(base_url);
}
let previous_generation = state.torrent_generation.lock().unwrap().take();
*state.torrent_server_base_url.lock().unwrap() = None;
*state.torrent_stream_link.lock().unwrap() = None;
*state.torrent_stream_file_id.lock().unwrap() = None;
let cleanup_dir = data_dir.to_path_buf();
let _ = tauri::async_runtime::spawn_blocking(move || {
let stopped = fluxa_streaming_engine::stop_torrent_server(previous_generation);
let _ = fs::remove_dir_all(cleanup_dir.join("torrent-cache"));
stopped
})
.await;
None
}
#[tauri::command]
async fn start_torrent_stream(
state: State<'_, DesktopState>,
@ -416,31 +454,8 @@ async fn start_torrent_stream(
.and_then(Value::as_str)
.map(str::to_ascii_lowercase)
});
let mut existing_base_url = state.torrent_server_base_url.lock().unwrap().clone();
let existing_link = state.torrent_stream_link.lock().unwrap().clone();
if let Some(base_url) = existing_base_url.clone() {
let healthy = tauri::async_runtime::spawn_blocking({
let base_url = base_url.clone();
move || torrent_server_healthy(&base_url)
})
.await
.unwrap_or(false);
if !healthy {
existing_base_url = None;
let previous_generation = state.torrent_generation.lock().unwrap().take();
*state.torrent_server_base_url.lock().unwrap() = None;
*state.torrent_stream_link.lock().unwrap() = None;
*state.torrent_stream_file_id.lock().unwrap() = None;
let cleanup_dir = data_dir.clone();
tauri::async_runtime::spawn_blocking(move || {
let stopped = fluxa_streaming_engine::stop_torrent_server(previous_generation);
let _ = fs::remove_dir_all(cleanup_dir.join("torrent-cache"));
stopped
})
.await
.unwrap_or(false);
}
}
let existing_base_url = ensure_healthy_torrent_base_url(&state, &data_dir).await;
let reuse_existing_server = existing_base_url.is_some();
let same_torrent = info_hash.as_ref().is_some_and(|hash| {
existing_link
@ -469,23 +484,35 @@ async fn start_torrent_stream(
Ok(stream_url)
}
pub(crate) async fn resolve_torrent_download_url(
state: &State<'_, DesktopState>,
stream_json: String,
) -> Result<String, String> {
let data_dir = state
.data_dir
.lock()
.unwrap()
.clone()
.ok_or_else(|| "app data dir is not ready".to_string())?;
let existing_base_url = ensure_healthy_torrent_base_url(state, &data_dir).await;
let (stream_url, base_url, _link, generation, _file_id) =
tauri::async_runtime::spawn_blocking(move || {
start_torrent_stream_inner(data_dir, stream_json, None, None, existing_base_url)
})
.await
.map_err(|e| e.to_string())??;
*state.torrent_server_base_url.lock().unwrap() = Some(base_url);
if let Some(generation) = generation {
*state.torrent_generation.lock().unwrap() = Some(generation);
}
Ok(stream_url)
}
#[tauri::command]
async fn stop_torrent_stream(state: State<'_, DesktopState>) -> Result<bool, String> {
*state.torrent_server_base_url.lock().unwrap() = None;
*state.torrent_stream_link.lock().unwrap() = None;
let was_playing = state.torrent_stream_link.lock().unwrap().take().is_some();
*state.torrent_stream_file_id.lock().unwrap() = None;
let generation = state.torrent_generation.lock().unwrap().take();
let data_dir = state.data_dir.lock().unwrap().clone();
let stopped = tauri::async_runtime::spawn_blocking(move || {
let stopped = fluxa_streaming_engine::stop_torrent_server(generation);
if let Some(data_dir) = data_dir {
let _ = fs::remove_dir_all(data_dir.join("torrent-cache"));
}
stopped
})
.await
.unwrap_or(false);
Ok(stopped)
Ok(was_playing)
}
#[tauri::command]
@ -813,6 +840,7 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
is_linux,
debug_log,
app_close_flush_done,
set_diagnostic_mode,
@ -842,6 +870,7 @@ pub fn run() {
library_continue_watching_delete,
core_invoke,
run_plugin_scraper,
stream_magnet_link,
start_torrent_stream,
stop_torrent_stream,
register_trailer_proxy_url,
@ -931,6 +960,11 @@ pub fn run() {
cast_proxy_serve,
player_torrent_stats,
])
.run(tauri::generate_context!())
.expect("error while running Fluxa Desktop");
.build(tauri::generate_context!())
.expect("error while building Fluxa Desktop")
.run(|_app_handle, event| {
if let tauri::RunEvent::Exit = event {
fluxa_streaming_engine::stop_torrent_server(None);
}
});
}

View file

@ -193,7 +193,7 @@ export default function App() {
setWelcomeCompleted,
} = useAppInit(updateState, setActiveRoute, storedPrefsRef);
const { playerLoadingOverlay, playerUrl, playerPlaybackError, playerSubtitleWarning, dismissSubtitleWarning, playerTitle, playerEpisodeTitle, playerEpisode, playerUsesTorrent, playerPosterUrl, playerLogoUrl, playerMetaId, playerSubtitleUrl, playerStreamHeaders, handlePlay, closePlayer, notifyFirstFrame, flushProgressOnQuit } = usePlayer({
const { playerLoadingOverlay, playerUrl, playerPlaybackError, playerSubtitleWarning, dismissSubtitleWarning, playerTitle, playerEpisodeTitle, playerEpisode, playerUsesTorrent, playerPosterUrl, playerLogoUrl, playerMetaId, playerSubtitleUrl, playerStreamHeaders, playingStreamRef, playingMetaRef, handlePlay, closePlayer, notifyFirstFrame, flushProgressOnQuit } = usePlayer({
stateRef,
activeProfile,
updateState,
@ -841,6 +841,8 @@ export default function App() {
metaId={playerMetaId}
initialSubtitleUrl={playerSubtitleUrl}
initialStreamHeaders={playerStreamHeaders}
streamRef={playingStreamRef}
metaRef={playingMetaRef}
playbackUrl={playerUrl}
prefs={prefs}
onDispatch={dispatch}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import { t } from '../i18n';
import { invoke } from '@tauri-apps/api/core';
import { listen, emit } from '@tauri-apps/api/event';
@ -11,10 +11,13 @@ import {
Cast,
ChevronLeft,
Clock,
Download,
Fullscreen,
GalleryVerticalEnd,
Gauge,
Info,
Link2,
Magnet,
Minimize2,
Pause,
PictureInPicture2,
@ -41,7 +44,10 @@ import { Toast } from './Toast';
import { NextEpCard } from './player/NextEpCard';
import { EpisodePanel, epLabel } from './player/EpisodePanel';
import type { EpisodeInfo } from './player/EpisodePanel';
import type { Video } from '../core/types';
import type { Meta, Stream, Video } from '../core/types';
import { streamMagnetLink, enqueueOfflineDownload } from '../core/engine';
import { buildOfflineDownloadRequest, streamDownloadLink, streamIsTorrent, streamSourceLink } from '../core/streamLinks';
import { ContextMenu } from './ui/ContextMenu';
import { TrackPopover, type SubtitleCaptureCue } from './player/TrackPopover';
import { CastPopover } from './player/CastPopover';
import { TorrentStatsPopover } from './player/TorrentStatsPopover';
@ -146,6 +152,8 @@ interface Props {
metaId?: string;
initialSubtitleUrl?: string;
initialStreamHeaders?: Record<string, string>;
streamRef?: RefObject<Stream | null>;
metaRef?: RefObject<Meta | null>;
playbackUrl?: string | null;
playbackError?: string | null;
subtitleWarning?: string[] | null;
@ -156,7 +164,7 @@ interface Props {
onDispatch?: (actionJson: string) => Promise<void> | void;
}
export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, initialEpisodeTitle, currentEpisode, isTorrentStream = false, initialPosterUrl, initialLogoUrl, metaId, initialSubtitleUrl, initialStreamHeaders, playbackUrl, playbackError, subtitleWarning, onDismissSubtitleWarning, softwareVideoActive = false, bannerOffset = 0, prefs, onDispatch }: Props) {
export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, initialEpisodeTitle, currentEpisode, isTorrentStream = false, initialPosterUrl, initialLogoUrl, metaId, initialSubtitleUrl, initialStreamHeaders, streamRef, metaRef, playbackUrl, playbackError, subtitleWarning, onDismissSubtitleWarning, softwareVideoActive = false, bannerOffset = 0, prefs, onDispatch }: Props) {
const [paused, setPaused] = useState(false);
const [muted, setMuted] = useState(false);
const [volumeLevel, setVolumeLevel] = useState(100);
@ -190,6 +198,8 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
const autoSkippedKeysRef = useRef<Set<string>>(new Set());
const [showNextEpCard, setShowNextEpCard] = useState(false);
const [trackPopover, setTrackPopover] = useState<'audio' | 'sub' | 'speed' | null>(null);
const [streamLinksMenuPoint, setStreamLinksMenuPoint] = useState<{ x: number; y: number } | null>(null);
const streamLinksBtnRef = useRef<HTMLButtonElement | null>(null);
const [miniPlayerActive, setMiniPlayerActive] = useState(false);
const miniPlayerActiveRef = useRef(false);
const preMiniPlayerSizeRef = useRef<PhysicalSize | null>(null);
@ -387,18 +397,33 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
};
}, [softwareVideoActive, onFirstFrame]);
const toggleFullscreen = useCallback(async () => {
const next = !isFullscreenRef.current;
const setPlayerFullscreen = useCallback(async (next: boolean) => {
isFullscreenRef.current = next;
await getCurrentWindow().setFullscreen(next);
const win = getCurrentWindow();
if (await invoke<boolean>('is_linux')) {
// Native xdg_toplevel fullscreen doesn't reliably composite our
// Wayland subsurface video layer on some compositors (e.g. Hyprland),
// so use a borderless maximize instead of true fullscreen on Linux.
await win.setDecorations(!next);
if (next) {
await win.maximize();
} else {
await win.unmaximize();
}
} else {
await win.setFullscreen(next);
}
}, []);
const toggleFullscreen = useCallback(async () => {
await setPlayerFullscreen(!isFullscreenRef.current);
}, [setPlayerFullscreen]);
const toggleMiniPlayer = useCallback(async () => {
const win = getCurrentWindow();
if (!miniPlayerActive) {
if (isFullscreenRef.current) {
isFullscreenRef.current = false;
await win.setFullscreen(false);
await setPlayerFullscreen(false);
}
try {
preMiniPlayerSizeRef.current = await win.outerSize();
@ -429,7 +454,7 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
setSuppressWindowGeometrySave(false);
setMiniPlayerActive(false);
}
}, [miniPlayerActive]);
}, [miniPlayerActive, setPlayerFullscreen]);
const flashFeedback = useCallback((icon: FeedbackFlash['icon'], label: string) => {
setFeedback({ icon, label });
@ -826,7 +851,7 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
if (contextMenu) { setContextMenu(null); return; }
if (showEpisodePanel) { setShowEpisodePanel(false); episodePanelOpenRef.current = false; return; }
if (trackPopover) { setTrackPopover(null); return; }
if (isFullscreenRef.current) { isFullscreenRef.current = false; void getCurrentWindow().setFullscreen(false); }
if (isFullscreenRef.current) { void setPlayerFullscreen(false); }
return;
}
if (e.code === 'Backspace') {
@ -1012,7 +1037,7 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [closePlayer, contextMenu, flashFeedback, nextEpSubtitle, playbackSpeed, shortcutOverrides, showEpisodePanel, showShortcutsHelp, startSeekOverlay, toggleFullscreen, toggleMiniPlayer, trackPopover, triggerActiveSkip]);
}, [closePlayer, contextMenu, flashFeedback, nextEpSubtitle, playbackSpeed, setPlayerFullscreen, shortcutOverrides, showEpisodePanel, showShortcutsHelp, startSeekOverlay, toggleFullscreen, toggleMiniPlayer, trackPopover, triggerActiveSkip]);
useEffect(() => {
return () => {
@ -1613,6 +1638,20 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
>
<PictureInPicture2 size={20} />
</button>
<button
ref={streamLinksBtnRef}
onClick={(e) => {
e.stopPropagation();
resetActivity();
const rect = streamLinksBtnRef.current?.getBoundingClientRect();
setStreamLinksMenuPoint(rect ? { x: Math.max(0, rect.right - 216), y: rect.bottom + 8 } : null);
}}
className="fluxa-ibtn"
style={{ ...styles.iconBtn, color: '#fff' }}
title={t('player.stream_links')}
>
<Link2 size={20} />
</button>
<button
ref={playerSettingsBtnRef}
onClick={(e) => { e.stopPropagation(); resetActivity(); setShowPlayerSettings((v) => !v); }}
@ -1737,6 +1776,23 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
/>
)}
<ContextMenu
point={streamLinksMenuPoint}
onClose={() => setStreamLinksMenuPoint(null)}
items={(() => {
const stream = streamRef?.current;
const meta = metaRef?.current;
if (!stream) return [];
const sourceLink = streamSourceLink(stream);
const downloadLink = streamDownloadLink(stream);
return [
...(sourceLink ? [{ icon: <Link2 size={15} />, label: t('player.copy_stream_link'), onSelect: () => { void navigator.clipboard.writeText(sourceLink); } }] : []),
...(streamIsTorrent(stream) ? [{ icon: <Magnet size={15} />, label: t('player.copy_magnet_link'), onSelect: () => { void streamMagnetLink(stream).then((link) => { if (link) void navigator.clipboard.writeText(link); }); } }] : []),
...(meta && downloadLink ? [{ icon: <Download size={15} />, label: t('player.download_this_video'), onSelect: () => { void enqueueOfflineDownload(buildOfflineDownloadRequest(meta, stream, currentEpisode)); } }] : []),
];
})()}
/>
{castPopoverOpen && (
<CastPopover
devices={castDevices}

View file

@ -1,17 +1,21 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { ChevronLeft, ChevronRight, Download, Link2, Magnet, Play } from 'lucide-react';
import { t } from '../../i18n';
import { EP, MS, SS, spinnerStyle } from './detailStyles';
import type { Meta, Stream, Video } from '../../core/types';
import { useDragScroll } from '../../hooks/useDragScroll';
import { streamMagnetLink, enqueueOfflineDownload } from '../../core/engine';
import { buildOfflineDownloadRequest, streamDownloadLink, streamIsTorrent, streamSourceLink } from '../../core/streamLinks';
import { ContextMenu } from '../ui/ContextMenu';
export function streamDisplayText(value: string | undefined): string | undefined {
const text = value?.replace(/\\r\\n|\\n|\\r/g, '\n').replace(/\r\n|\r/g, '\n').trim();
return text || undefined;
}
export function SourceRow({ stream, onClick }: { stream: Stream; onClick: () => void }) {
export function SourceRow({ stream, onClick, meta, episode }: { stream: Stream; onClick: () => void; meta?: Meta; episode?: Video | null }) {
const [hovered, setHovered] = useState(false);
const [menuPoint, setMenuPoint] = useState<{ x: number; y: number } | null>(null);
const heading = streamDisplayText(stream.name) || streamDisplayText(stream.title) || streamDisplayText(stream.description) || t('player.source');
const seenLines = new Set<string>();
const lines = [stream.title, stream.description]
@ -21,21 +25,40 @@ export function SourceRow({ stream, onClick }: { stream: Stream; onClick: () =>
seenLines.add(value);
return true;
});
const isTorrent = streamIsTorrent(stream);
const sourceLink = streamSourceLink(stream);
const downloadLink = streamDownloadLink(stream);
return (
<button
style={{ ...SS.streamRow, background: hovered ? '#181818' : '#101010', color: '#FFF', boxShadow: hovered ? '0 0 0 0.125rem rgba(255,255,255,0.22)' : 'none' }}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '0.5rem', width: '100%' }}>
<span style={SS.streamName}>{heading}</span>
{stream.addonName && <span style={{ ...SS.streamAddon, color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>{stream.addonName}</span>}
</div>
{lines.map((line, index) => (
<span key={`${line}:${index}`} style={SS.streamDesc}>{line}</span>
))}
</button>
<>
<button
style={{ ...SS.streamRow, background: hovered ? '#181818' : '#101010', color: '#FFF', boxShadow: hovered ? '0 0 0 0.125rem rgba(255,255,255,0.22)' : 'none' }}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onContextMenu={(e) => { e.preventDefault(); setMenuPoint({ x: e.clientX, y: e.clientY }); }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '0.5rem', width: '100%' }}>
<span style={SS.streamName}>{heading}</span>
{stream.addonName && <span style={{ ...SS.streamAddon, color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>{stream.addonName}</span>}
</div>
{lines.map((line, index) => (
<span key={`${line}:${index}`} style={SS.streamDesc}>{line}</span>
))}
</button>
<ContextMenu
point={menuPoint}
onClose={() => setMenuPoint(null)}
items={[
{ icon: <Play size={15} />, label: t('player.play'), onSelect: onClick },
...(sourceLink ? [{ icon: <Link2 size={15} />, label: t('sources.copy_stream_link'), onSelect: () => { void navigator.clipboard.writeText(sourceLink); } }] : []),
...(isTorrent ? [{ icon: <Magnet size={15} />, label: t('sources.copy_magnet_link'), onSelect: () => { void streamMagnetLink(stream).then((link) => { if (link) void navigator.clipboard.writeText(link); }); } }] : []),
...(meta ? [{ icon: <Download size={15} />, label: t('sources.download_this_video'), onSelect: () => { void enqueueOfflineDownload(buildOfflineDownloadRequest(meta, stream, episode)); } }] : []),
...(downloadLink ? [{ icon: <Link2 size={15} />, label: t('sources.copy_video_download_link'), onSelect: () => { void navigator.clipboard.writeText(downloadLink); } }] : []),
]}
/>
</>
);
}
@ -213,7 +236,7 @@ export function MovieSourcePanel({
{visibleStreams.length > 0 && (
<div style={EP.inlineStreamList}>
{visibleStreams.map((stream, i) => (
<SourceRow key={`${stream.addonName ?? ''}:${stream.url ?? stream.infoHash ?? i}`} stream={stream} onClick={() => onPlay(stream)} />
<SourceRow key={`${stream.addonName ?? ''}:${stream.url ?? stream.infoHash ?? i}`} stream={stream} onClick={() => onPlay(stream)} meta={meta} />
))}
{isLoading && <div style={{ ...SS.center, padding: '1rem 0' }}><div style={{ ...spinnerStyle, width: '1.25rem', height: '1.25rem' }} /></div>}
</div>
@ -350,7 +373,7 @@ export function InlineSourceList({
{visibleStreams.length > 0 && (
<div style={EP.inlineStreamList}>
{visibleStreams.map((stream, i) => (
<SourceRow key={`${stream.addonName ?? ''}:${stream.url ?? stream.infoHash ?? i}`} stream={stream} onClick={() => onPlay(stream)} />
<SourceRow key={`${stream.addonName ?? ''}:${stream.url ?? stream.infoHash ?? i}`} stream={stream} onClick={() => onPlay(stream)} meta={meta} episode={episode} />
))}
{isLoading && <div style={{ ...SS.center, padding: '1rem 0' }}><div style={{ ...spinnerStyle, width: '1.25rem', height: '1.25rem' }} /></div>}
</div>

View file

@ -212,6 +212,10 @@ export async function enqueueOfflineDownload(request: unknown): Promise<unknown
return raw ? JSON.parse(raw) : null;
}
export async function streamMagnetLink(stream: unknown): Promise<string | null> {
return invoke<string | null>('stream_magnet_link', { streamJson: JSON.stringify(stream) });
}
export async function coreInvoke<T>(method: CoreMethod, argsJson: string): Promise<T | null> {
return Sentry.startSpan({ name: `coreInvoke:${method}`, op: 'fluxa.core' }, async () => {
const t0 = performance.now();

24
src/core/streamLinks.ts Normal file
View file

@ -0,0 +1,24 @@
import type { Meta, Stream, Video } from './types';
export function streamSourceLink(stream: Stream): string | undefined {
return stream.url ?? stream.infoHash ?? undefined;
}
export function streamDownloadLink(stream: Stream): string | undefined {
return stream.playableUrl ?? stream.url ?? undefined;
}
export function streamIsTorrent(stream: Stream): boolean {
if (stream.infoHash || stream.isTorrent) return true;
const link = stream.url?.toLowerCase();
return !!link && (link.startsWith('magnet:') || link.startsWith('stremio://torrent/') || link.startsWith('infohash:'));
}
export function buildOfflineDownloadRequest(meta: Meta, stream: Stream, video?: Video | null) {
return {
downloadId: crypto.randomUUID(),
meta,
video: video ?? undefined,
stream,
};
}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import { invoke } from '@tauri-apps/api/core';
import * as Sentry from '@sentry/react';
import { dispatchAction, coreDetectAnimePlayback, corePlaybackIntroLookupContentId, corePlaybackPreparePlan, coreResolveNextEpisode, coreCanPrefetchNextEpisode, coreSelectNextEpisodeStream, coreTorrentStatusInfo } from '../core/engine';
@ -87,6 +87,8 @@ interface UsePlayerResult {
playerMetaId: string | undefined;
playerSubtitleUrl: string | undefined;
playerStreamHeaders: Record<string, string> | undefined;
playingStreamRef: RefObject<Stream | null>;
playingMetaRef: RefObject<Meta | null>;
playerPlaybackError: string | null;
playerSubtitleWarning: string[] | null;
dismissSubtitleWarning: () => void;
@ -988,5 +990,5 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
setPlayerSubtitleWarning(null);
}, []);
return { playerLoadingOverlay, playerUrl, playerPlaybackError, playerSubtitleWarning, dismissSubtitleWarning, playerTitle, playerEpisodeTitle, playerEpisode, playerUsesTorrent, playerPosterUrl, playerLogoUrl, playerMetaId, playerSubtitleUrl, playerStreamHeaders, handlePlay, closePlayer, notifyFirstFrame, flushProgressOnQuit: saveProgressTick };
return { playerLoadingOverlay, playerUrl, playerPlaybackError, playerSubtitleWarning, dismissSubtitleWarning, playerTitle, playerEpisodeTitle, playerEpisode, playerUsesTorrent, playerPosterUrl, playerLogoUrl, playerMetaId, playerSubtitleUrl, playerStreamHeaders, playingStreamRef, playingMetaRef, handlePlay, closePlayer, notifyFirstFrame, flushProgressOnQuit: saveProgressTick };
}

View file

@ -1448,5 +1448,13 @@
"format.countdown_compact_d": "%sd",
"format.countdown_compact_hm": "%sh %sm",
"format.countdown_compact_h": "%sh",
"format.countdown_compact_m": "%sm"
"format.countdown_compact_m": "%sm",
"player.stream_links": "Stream links",
"player.copy_stream_link": "Copy stream link",
"player.copy_magnet_link": "Copy magnet link",
"player.download_this_video": "Download this video",
"sources.copy_stream_link": "Copy Stream Link",
"sources.copy_magnet_link": "Copy Magnet Link",
"sources.download_this_video": "Download This Video",
"sources.copy_video_download_link": "Copy Video Download Link"
}

View file

@ -1448,5 +1448,13 @@
"format.countdown_compact_d": "%s g",
"format.countdown_compact_hm": "%s sa %s dk",
"format.countdown_compact_h": "%s sa",
"format.countdown_compact_m": "%s dk"
"format.countdown_compact_m": "%s dk",
"player.stream_links": "Yayın bağlantıları",
"player.copy_stream_link": "Yayın bağlantısını kopyala",
"player.copy_magnet_link": "Magnet bağlantısını kopyala",
"player.download_this_video": "Bu videoyu indir",
"sources.copy_stream_link": "Yayın Bağlantısını Kopyala",
"sources.copy_magnet_link": "Magnet Bağlantısını Kopyala",
"sources.download_this_video": "Bu Videoyu İndir",
"sources.copy_video_download_link": "Video İndirme Bağlantısını Kopyala"
}