mirror of
https://github.com/FluxaMedia/fluxa-desktop.git
synced 2026-07-26 20:02:09 +00:00
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:
parent
dfc1299215
commit
736b99313e
11 changed files with 275 additions and 82 deletions
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
24
src/core/streamLinks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue