diff --git a/client/src-tauri/src/commands/saves_screenshots.rs b/client/src-tauri/src/commands/saves_screenshots.rs index 9e05e19a..3ea858bf 100644 --- a/client/src-tauri/src/commands/saves_screenshots.rs +++ b/client/src-tauri/src/commands/saves_screenshots.rs @@ -1,6 +1,6 @@ -//! Tauri command wrappers for save data + screenshot listing. +//! Tauri command wrappers for save data + screenshot + video-clip listing. -use ps5upload_core::saves::{list_saves, list_screenshots}; +use ps5upload_core::saves::{list_saves, list_screenshots, list_videos}; use serde_json::Value as JsonValue; #[tauri::command] @@ -21,3 +21,12 @@ pub async fn screenshots_list(addr: String) -> Result { .map(|s| serde_json::to_value(s).unwrap_or(serde_json::json!({}))) .map_err(|e| format!("screenshots: {e}")) } + +#[tauri::command] +pub async fn videos_list(addr: String) -> Result { + tokio::task::spawn_blocking(move || list_videos(&addr)) + .await + .map_err(|e| format!("videos task: {e}"))? + .map(|s| serde_json::to_value(s).unwrap_or(serde_json::json!({}))) + .map_err(|e| format!("videos: {e}")) +} diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 0a8ca97c..1cae8045 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -294,6 +294,7 @@ pub fn run() { // ── Save data + screenshot listing ────────────────────── commands::saves_list, commands::screenshots_list, + commands::videos_list, commands::screenshot_convert, // ── Save data .zip backup/restore ─────────────────────── // The Saves screen uses these to wrap a downloaded save diff --git a/client/src/App.tsx b/client/src/App.tsx index 192fe95b..98b749ce 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -40,6 +40,7 @@ const FirstRunScreen = lazy(() => import("./screens/FirstRun")); const SavesScreen = lazy(() => import("./screens/Saves")); const ProcessesScreen = lazy(() => import("./screens/Processes")); const ScreenshotsScreen = lazy(() => import("./screens/Screenshots")); +const VideosScreen = lazy(() => import("./screens/Videos")); const StatsScreen = lazy(() => import("./screens/Stats")); const ShellScreen = lazy(() => import("./screens/Shell")); const DiskUsageScreen = lazy(() => import("./screens/DiskUsage")); @@ -212,6 +213,14 @@ export default function App() { } /> + }> + + + } + /> { return invoke("screenshots_list", { addr }); } +/** List gameplay video clips from the PS5 (`/user/av_contents/video`, + * `.webm`/`.mp4`). Same `{path,size,mtime}` shape as screenshots so the + * Videos screen reuses the row + generic download path. Video clips + * don't need the `.jxr → PNG` conversion screenshots do — they download + * as-is and play in any modern player. */ +export async function videosList(addr: string): Promise { + return invoke("videos_list", { addr }); +} + /** Convert a downloaded PS5 screenshot (`.jxr` / JPEG XR — HDR, not * viewable in normal apps) into an SDR PNG. Decoding + HDR→SDR * tone-mapping happen in-process in the Rust backend (jxrlib, no external diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index 5d85263b..9a66a7ef 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -1057,6 +1057,23 @@ screenshots_no_payload: "Connect to your PS5 first.", screenshots_select: "Toggle select", screenshots_select_all: "Select all", screenshots_title: "Screenshots", +videos_title: "Video clips", +videos_description: + "Gameplay video clips saved on the PS5 (Capture Gallery). Download to your computer, or delete via the File System tab. Clips download as-is — no conversion needed.", +videos_no_payload: "Connect to your PS5 first.", +videos_error: "Couldn't list video clips", +videos_empty: "No clips found — record a gameplay video on the PS5 first.", +videos_select: "Toggle select", +videos_select_all: "Select all", +videos_deselect_all: "Deselect all", +videos_download: "Download", +videos_preview: "Preview", +videos_preview_loading: "Downloading clip…", +videos_dest_pick: "Pick a folder to download into", +videos_bulk_dest: "Pick a folder for {n} clips", +videos_bulk_download: "Download {n}", +videos_bulk_partial: + "Downloaded {ok}; {failed} failed — failed ones stay selected to retry.", search_export_csv: "Export CSV", search_export_json: "Export JSON", search_remove: "Remove saved search", @@ -1506,6 +1523,7 @@ upload_dest_will_create: "If the destination folder doesn't exist yet on the PS5 dashboard: "Dashboard", saves: "Save data", screenshots: "Screenshots", +videos: "Video clips", disk_usage: "Disk usage", payloads: "Payloads", stats: "Stats", diff --git a/client/src/layout/Sidebar.tsx b/client/src/layout/Sidebar.tsx index ba3a9cda..f34a2d0b 100644 --- a/client/src/layout/Sidebar.tsx +++ b/client/src/layout/Sidebar.tsx @@ -17,6 +17,7 @@ import { Globe, Save, Image as ImageIcon, + Video as VideoIcon, Settings as SettingsIcon, Info, Sun, @@ -144,6 +145,7 @@ const items: NavItem[] = [ fallback: "Screenshots", icon: ImageIcon, }, + { to: "/videos", key: "videos", fallback: "Video clips", icon: VideoIcon }, // ─ Browse PS5: navigate what's on the console ─ { diff --git a/client/src/screens/Videos/index.tsx b/client/src/screens/Videos/index.tsx new file mode 100644 index 00000000..47795c8b --- /dev/null +++ b/client/src/screens/Videos/index.tsx @@ -0,0 +1,460 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Video as VideoIcon, + RefreshCw, + Loader2, + Download, + CheckSquare, + Square, + Eye, + X, +} from "lucide-react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { + waitForJob, + videosList, + startTransferDownload, + saveArchiveMakeTemp, + saveArchiveCleanupTemp, + type ScreenshotEntry, +} from "../../api/ps5"; +import { useConnectionStore, PS5_PAYLOAD_PORT } from "../../state/connection"; +import { mgmtAddr } from "../../lib/addr"; +import { useScrollLock } from "../../lib/useScrollLock"; +import { useStaleHostGuard } from "../../lib/staleHostGuard"; +import { PageHeader, Button, EmptyState, ErrorCard } from "../../components"; +import { useTr } from "../../state/lang"; +import { pickPath } from "../../lib/pickPath"; +import { formatBytes } from "../../lib/format"; +import { basename } from "../../lib/uploadDest"; +import { isMobile } from "../../lib/platform"; + +/** Join a dir + name the same way the screenshot flow does (mirrors + * lib/screenshotConvert.joinDir, kept local so Videos has no dependency on + * the JXR-conversion module it doesn't otherwise need). */ +function joinDir(dir: string, name: string): string { + return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`; +} + +/** + * Video clips screen — mirrors Screenshots but simpler: PS5 gameplay clips + * (`.webm`/`.mp4` under `/user/av_contents/video`) are already playable + * formats, so there's no `.jxr → PNG` conversion step. List with checkbox + * selection, per-row + bulk download to the host, and an inline `