diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 93d0f57..755a6e1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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", { diff --git a/src-tauri/src/downloads.rs b/src-tauri/src/downloads.rs index da294ff..0f4cfa7 100644 --- a/src-tauri/src/downloads.rs +++ b/src-tauri/src/downloads.rs @@ -135,6 +135,7 @@ async fn run_download( request_json: String, playback_url: String, video_file_name: String, + is_local_source: bool, cancel: Arc, ) { 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::(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, id: String) -> Result } #[tauri::command] -pub fn resume_offline_download( +pub async fn resume_offline_download( app: AppHandle, - state: State, + 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(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c8ab22b..bcc367a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + FluxaCore::stream_magnet_link_json(&stream_json) +} + +async fn ensure_healthy_torrent_base_url( + state: &State<'_, DesktopState>, + data_dir: &std::path::Path, +) -> Option { + 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 { + 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 { - *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); + } + }); } diff --git a/src/App.tsx b/src/App.tsx index eebba7d..653982f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} diff --git a/src/components/ReactPlayerOverlay.tsx b/src/components/ReactPlayerOverlay.tsx index f654305..c72c82e 100644 --- a/src/components/ReactPlayerOverlay.tsx +++ b/src/components/ReactPlayerOverlay.tsx @@ -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; + streamRef?: RefObject; + metaRef?: RefObject; playbackUrl?: string | null; playbackError?: string | null; subtitleWarning?: string[] | null; @@ -156,7 +164,7 @@ interface Props { onDispatch?: (actionJson: string) => Promise | 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>(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(null); const [miniPlayerActive, setMiniPlayerActive] = useState(false); const miniPlayerActiveRef = useRef(false); const preMiniPlayerSizeRef = useRef(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('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 > + + <> + + setMenuPoint(null)} + items={[ + { icon: , label: t('player.play'), onSelect: onClick }, + ...(sourceLink ? [{ icon: , label: t('sources.copy_stream_link'), onSelect: () => { void navigator.clipboard.writeText(sourceLink); } }] : []), + ...(isTorrent ? [{ icon: , label: t('sources.copy_magnet_link'), onSelect: () => { void streamMagnetLink(stream).then((link) => { if (link) void navigator.clipboard.writeText(link); }); } }] : []), + ...(meta ? [{ icon: , label: t('sources.download_this_video'), onSelect: () => { void enqueueOfflineDownload(buildOfflineDownloadRequest(meta, stream, episode)); } }] : []), + ...(downloadLink ? [{ icon: , label: t('sources.copy_video_download_link'), onSelect: () => { void navigator.clipboard.writeText(downloadLink); } }] : []), + ]} + /> + ); } @@ -213,7 +236,7 @@ export function MovieSourcePanel({ {visibleStreams.length > 0 && (
{visibleStreams.map((stream, i) => ( - onPlay(stream)} /> + onPlay(stream)} meta={meta} /> ))} {isLoading &&
}
@@ -350,7 +373,7 @@ export function InlineSourceList({ {visibleStreams.length > 0 && (
{visibleStreams.map((stream, i) => ( - onPlay(stream)} /> + onPlay(stream)} meta={meta} episode={episode} /> ))} {isLoading &&
}
diff --git a/src/core/engine.ts b/src/core/engine.ts index 48e9cb3..6d6ec10 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -212,6 +212,10 @@ export async function enqueueOfflineDownload(request: unknown): Promise { + return invoke('stream_magnet_link', { streamJson: JSON.stringify(stream) }); +} + export async function coreInvoke(method: CoreMethod, argsJson: string): Promise { return Sentry.startSpan({ name: `coreInvoke:${method}`, op: 'fluxa.core' }, async () => { const t0 = performance.now(); diff --git a/src/core/streamLinks.ts b/src/core/streamLinks.ts new file mode 100644 index 0000000..22ec7c4 --- /dev/null +++ b/src/core/streamLinks.ts @@ -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, + }; +} diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index c6aefe6..f34e856 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -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 | undefined; + playingStreamRef: RefObject; + playingMetaRef: RefObject; 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 }; } diff --git a/src/i18n/english_us.json b/src/i18n/english_us.json index f0145b5..5ba35f3 100644 --- a/src/i18n/english_us.json +++ b/src/i18n/english_us.json @@ -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" } diff --git a/src/i18n/tr_tr.json b/src/i18n/tr_tr.json index 84cd070..02058a2 100644 --- a/src/i18n/tr_tr.json +++ b/src/i18n/tr_tr.json @@ -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" }