1739 lines
65 KiB
Rust
1739 lines
65 KiB
Rust
use anyhow::Result;
|
||
use std::env;
|
||
use std::io::Write;
|
||
#[cfg(feature = "player")]
|
||
use std::os::unix::io::AsRawFd;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Child, Command, Stdio};
|
||
|
||
mod api_client;
|
||
mod asr_overlay;
|
||
mod chunk_selector;
|
||
mod selector;
|
||
|
||
use api_client::ApiClient;
|
||
use selector::{VideoEntry, VideoSelector};
|
||
|
||
#[allow(dead_code)]
|
||
const STATUS_BAR_HEIGHT: i32 = 50;
|
||
#[allow(dead_code)]
|
||
const FONT_SIZE: i32 = 20;
|
||
#[allow(dead_code)]
|
||
const ASR_OVERLAY_HEIGHT: i32 = 80;
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
#[allow(dead_code)]
|
||
enum TerminalCommand {
|
||
Pause,
|
||
Sound,
|
||
SeekBackward,
|
||
SeekForward,
|
||
ToggleStatusBar,
|
||
ToggleAsr,
|
||
Download,
|
||
SyncIncrease,
|
||
SyncDecrease,
|
||
Quit,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
#[allow(dead_code)]
|
||
struct VideoInfo {
|
||
width: u32,
|
||
height: u32,
|
||
fps: f64,
|
||
total_frames: u64,
|
||
duration: f64,
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
struct PlayerState {
|
||
current_time: f64,
|
||
current_frame: u64,
|
||
is_paused: bool,
|
||
sound_on: bool,
|
||
quit: bool,
|
||
status_bar_visible: bool,
|
||
asr_overlay_visible: bool,
|
||
}
|
||
|
||
struct Config {
|
||
download_dir: PathBuf,
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn start_sound_process(stream_url: &str, start_time: f64) -> Option<Child> {
|
||
Command::new(if cfg!(target_os = "macos") {
|
||
"/opt/homebrew/bin/ffplay"
|
||
} else {
|
||
"ffplay"
|
||
})
|
||
.args([
|
||
"-nodisp",
|
||
"-autoexit",
|
||
"-ss",
|
||
&format!("{:.2}", start_time),
|
||
stream_url,
|
||
])
|
||
.spawn()
|
||
.ok()
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
struct FormatOption {
|
||
format_id: String,
|
||
resolution: String,
|
||
ext: String,
|
||
note: String,
|
||
filesize: String,
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn load_config() -> Config {
|
||
let config_path = PathBuf::from(env::var("HOME").unwrap_or_default())
|
||
.join(".config")
|
||
.join("video_player")
|
||
.join("config.toml");
|
||
|
||
if config_path.exists() {
|
||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||
for line in content.lines() {
|
||
let line = line.trim();
|
||
if line.starts_with("download_dir") && line.contains('=') {
|
||
let value = line.split('=').nth(1).unwrap_or("").trim();
|
||
let path = value
|
||
.trim_matches('"')
|
||
.replace("~", &env::var("HOME").unwrap_or_default());
|
||
return Config {
|
||
download_dir: PathBuf::from(path),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Config {
|
||
download_dir: PathBuf::from(env::var("HOME").unwrap_or_default()).join("Downloads"),
|
||
}
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn list_available_formats(video_url: &str) -> Result<Vec<FormatOption>> {
|
||
println!("Fetching available formats...");
|
||
let output = Command::new("yt-dlp")
|
||
.args(["-F", "--no-download", video_url])
|
||
.output()?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
anyhow::bail!("Failed to list formats: {}", stderr);
|
||
}
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let mut formats = Vec::new();
|
||
|
||
for line in stdout.lines() {
|
||
let line = line.trim();
|
||
if line.is_empty()
|
||
|| line.starts_with("ID")
|
||
|| line.starts_with("---")
|
||
|| line.contains("storyboard")
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if let Some(first_part) = line.split('|').next() {
|
||
let parts: Vec<&str> = first_part.split_whitespace().collect();
|
||
if parts.len() >= 3 {
|
||
let format_id = parts[0].to_string();
|
||
let ext = parts[1].to_string();
|
||
let resolution = parts[2].to_string();
|
||
|
||
if ext == "mp4" || ext == "webm" || ext == "m4a" {
|
||
if !resolution.contains("x") {
|
||
continue;
|
||
}
|
||
|
||
let note = if line.contains("|") {
|
||
line.split('|')
|
||
.nth(1)
|
||
.map(|s| s.trim().to_string())
|
||
.unwrap_or_default()
|
||
} else {
|
||
String::new()
|
||
};
|
||
|
||
formats.push(FormatOption {
|
||
format_id,
|
||
resolution,
|
||
ext,
|
||
note,
|
||
filesize: String::new(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
formats.sort_by(|a, b| {
|
||
let a_h = a
|
||
.resolution
|
||
.split('x')
|
||
.nth(1)
|
||
.unwrap_or("0")
|
||
.parse::<u32>()
|
||
.unwrap_or(0);
|
||
let b_h = b
|
||
.resolution
|
||
.split('x')
|
||
.nth(1)
|
||
.unwrap_or("0")
|
||
.parse::<u32>()
|
||
.unwrap_or(0);
|
||
b_h.cmp(&a_h)
|
||
});
|
||
|
||
Ok(formats)
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn show_format_menu(formats: &[FormatOption], term_fd: libc::c_int) -> Option<usize> {
|
||
for (i, fmt) in formats.iter().enumerate().take(10) {
|
||
println!("[{}] {} {} ({})", i + 1, fmt.resolution, fmt.ext, fmt.note);
|
||
}
|
||
println!("----------------------------------------");
|
||
print!("Enter choice (default: 1): ");
|
||
|
||
if std::io::stdout().flush().is_err() {
|
||
return None;
|
||
}
|
||
|
||
unsafe {
|
||
let mut termios = std::mem::zeroed();
|
||
libc::tcgetattr(term_fd, &mut termios);
|
||
let mut normal = termios;
|
||
libc::cfmakeraw(&mut normal);
|
||
libc::tcsetattr(term_fd, libc::TCSANOW, &normal);
|
||
|
||
let mut input = String::new();
|
||
let result = std::io::stdin().read_line(&mut input);
|
||
|
||
libc::tcsetattr(term_fd, libc::TCSANOW, &termios);
|
||
|
||
if result.is_ok() {
|
||
let input = input.trim();
|
||
if input.is_empty() {
|
||
return Some(0);
|
||
}
|
||
if let Ok(choice) = input.parse::<usize>() {
|
||
if choice >= 1 && choice <= formats.len() {
|
||
return Some(choice - 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn download_video(video_url: &str, format_id: &str, download_dir: &Path) -> Result<String> {
|
||
println!("Downloading video to {:?}...", download_dir);
|
||
|
||
std::fs::create_dir_all(download_dir)?;
|
||
|
||
let output = Command::new("yt-dlp")
|
||
.args([
|
||
"-f",
|
||
format_id,
|
||
"-o",
|
||
&format!("{}/%(title)s.%(ext)s", download_dir.display()),
|
||
video_url,
|
||
])
|
||
.output()?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
anyhow::bail!("Download failed: {}", stderr);
|
||
}
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
for line in stdout.lines() {
|
||
if line.contains("Merging")
|
||
|| line.contains("Destination")
|
||
|| line.contains(".mp4")
|
||
|| line.contains(".webm")
|
||
{
|
||
if let Some(path) = line.split("Destination: ").nth(1) {
|
||
let path = path.trim();
|
||
if Path::new(path).exists() {
|
||
return Ok(path.to_string());
|
||
}
|
||
}
|
||
if line.contains(".mp4") || line.contains(".webm") {
|
||
let filename = line.split_whitespace().last().unwrap_or("");
|
||
let full_path = download_dir.join(filename);
|
||
if full_path.exists() {
|
||
return Ok(full_path.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for entry in (std::fs::read_dir(download_dir)?).flatten() {
|
||
let path = entry.path();
|
||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||
if ext == "mp4" || ext == "webm" || ext == "mkv" {
|
||
return Ok(path.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
|
||
anyhow::bail!("Could not find downloaded file")
|
||
}
|
||
|
||
fn is_youtube_url(input: &str) -> bool {
|
||
input.starts_with("http://")
|
||
|| input.starts_with("https://")
|
||
|| input.contains("youtube.com")
|
||
|| input.contains("youtu.be")
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn get_youtube_stream_url(video_url: &str) -> Result<String> {
|
||
println!("Getting video stream from YouTube...");
|
||
let output = Command::new("yt-dlp")
|
||
.args(["-f", "best[ext=mp4][vcodec!=none]", "-g", video_url])
|
||
.output()?;
|
||
|
||
if !output.status.success() {
|
||
let output = Command::new("yt-dlp")
|
||
.args(["-f", "best", "-g", video_url])
|
||
.output()?;
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
anyhow::bail!("yt-dlp failed: {}", stderr);
|
||
}
|
||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
if url.is_empty() {
|
||
anyhow::bail!("yt-dlp returned empty URL");
|
||
}
|
||
println!("Stream URL obtained");
|
||
return Ok(url);
|
||
}
|
||
|
||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
if url.is_empty() {
|
||
anyhow::bail!("yt-dlp returned empty URL");
|
||
}
|
||
println!("Stream URL obtained");
|
||
Ok(url)
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn get_video_info(video_path: &str) -> Result<VideoInfo> {
|
||
let output = Command::new("ffprobe")
|
||
.args([
|
||
"-v",
|
||
"error",
|
||
"-select_streams",
|
||
"v:0",
|
||
"-show_entries",
|
||
"stream=width,height,r_frame_rate,nb_frames,duration",
|
||
"-of",
|
||
"json",
|
||
video_path,
|
||
])
|
||
.output();
|
||
|
||
match output {
|
||
Ok(output) if output.status.success() => {
|
||
let json_str = String::from_utf8_lossy(&output.stdout);
|
||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
|
||
let stream = &json["streams"][0];
|
||
return Ok(VideoInfo {
|
||
width: stream["width"].as_u64().unwrap_or(1280) as u32,
|
||
height: stream["height"].as_u64().unwrap_or(720) as u32,
|
||
fps: parse_fps(stream["r_frame_rate"].as_str().unwrap_or("30/1")),
|
||
total_frames: stream["nb_frames"].as_u64().unwrap_or(0),
|
||
duration: stream["duration"]
|
||
.as_str()
|
||
.and_then(|s| s.parse::<f64>().ok())
|
||
.unwrap_or(0.0),
|
||
});
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
Ok(VideoInfo {
|
||
width: 1280,
|
||
height: 720,
|
||
fps: 30.0,
|
||
total_frames: 0,
|
||
duration: 0.0,
|
||
})
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn parse_fps(fps_str: &str) -> f64 {
|
||
let parts: Vec<&str> = fps_str.split('/').collect();
|
||
if parts.len() == 2 {
|
||
let num: f64 = parts[0].parse().unwrap_or(30.0);
|
||
let den: f64 = parts[1].parse().unwrap_or(1.0);
|
||
if den > 0.0 {
|
||
num / den
|
||
} else {
|
||
30.0
|
||
}
|
||
} else {
|
||
fps_str.parse().unwrap_or(30.0)
|
||
}
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn format_time(seconds: f64) -> String {
|
||
let hours = (seconds / 3600.0).floor() as u32;
|
||
let minutes = ((seconds % 3600.0) / 60.0).floor() as u32;
|
||
let secs = (seconds % 60.0).floor() as u32;
|
||
let millis = ((seconds % 1.0) * 100.0).floor() as u32;
|
||
format!("{:02}:{:02}:{:02}.{:02}", hours, minutes, secs, millis)
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn get_video_duration(video_path: &str) -> f64 {
|
||
let output = std::process::Command::new("ffprobe")
|
||
.args([
|
||
"-v",
|
||
"error",
|
||
"-show_entries",
|
||
"format=duration",
|
||
"-of",
|
||
"default=noprint_wrappers=1:nokey=1",
|
||
video_path,
|
||
])
|
||
.output();
|
||
|
||
match output {
|
||
Ok(out) if out.status.success() => {
|
||
let duration_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||
duration_str.parse::<f64>().unwrap_or(0.0)
|
||
}
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
fn lookup_video_uuid(video_path: &str) -> Option<String> {
|
||
use std::process::Command as StdCommand;
|
||
|
||
// Try to find UUID from database by matching file_path
|
||
let output = StdCommand::new("psql")
|
||
.args([
|
||
"-U",
|
||
"accusys",
|
||
"-h",
|
||
"localhost",
|
||
"-d",
|
||
"momentry",
|
||
"-t",
|
||
"-A",
|
||
"-c",
|
||
&format!(
|
||
"SELECT uuid FROM videos WHERE file_path LIKE '%{}%' LIMIT 1",
|
||
video_path.split('/').next_back().unwrap_or("")
|
||
),
|
||
])
|
||
.output()
|
||
.ok()?;
|
||
|
||
if output.status.success() {
|
||
let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
if !uuid.is_empty() {
|
||
return Some(uuid);
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
#[cfg(not(feature = "player"))]
|
||
fn draw_status_bar(
|
||
_video_info: &VideoInfo,
|
||
_state: &PlayerState,
|
||
_sync_delay_ms: u64,
|
||
) -> Result<String> {
|
||
Ok(format!(
|
||
"{:.2}s / {:.2}s",
|
||
_state.current_time, _video_info.duration
|
||
))
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
#[cfg(feature = "player")]
|
||
fn draw_status_bar(
|
||
_canvas: &mut (),
|
||
_font: &(),
|
||
_video_info: &VideoInfo,
|
||
_state: &PlayerState,
|
||
_sync_delay_ms: u64,
|
||
) -> Result<String> {
|
||
Ok(format!(
|
||
"{:.2}s / {:.2}s",
|
||
_state.current_time, _video_info.duration
|
||
))
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn start_ffmpeg(
|
||
stream_url: &str,
|
||
video_width: u32,
|
||
video_height: u32,
|
||
video_fps: f64,
|
||
seek_time: f64,
|
||
) -> Result<Child> {
|
||
let ffmpeg_path = if cfg!(target_os = "macos") {
|
||
"/opt/homebrew/bin/ffmpeg"
|
||
} else {
|
||
"ffmpeg"
|
||
};
|
||
let fps_str = format!("{:.2}", video_fps);
|
||
|
||
let mut cmd = Command::new(ffmpeg_path);
|
||
|
||
if seek_time > 0.0 {
|
||
cmd.args(["-ss", &format!("{:.2}", seek_time)]);
|
||
}
|
||
|
||
cmd.args([
|
||
"-i",
|
||
stream_url,
|
||
"-vf",
|
||
&format!(
|
||
"scale={}:{}:force_original_aspect_ratio=decrease",
|
||
video_width, video_height
|
||
),
|
||
"-f",
|
||
"rawvideo",
|
||
"-pix_fmt",
|
||
"rgb24",
|
||
"-r",
|
||
&fps_str,
|
||
"-",
|
||
])
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::null())
|
||
.spawn()
|
||
.map_err(|e| anyhow::anyhow!("Failed to start ffmpeg: {}", e))
|
||
}
|
||
|
||
#[cfg(not(feature = "player"))]
|
||
fn run_player(_video_path: &str, _video_uuid: Option<String>) -> Result<()> {
|
||
println!("Player not available - SDL2 not configured");
|
||
println!("Playing: {} (UUID: {:?})", _video_path, _video_uuid);
|
||
println!("(This is a stub - full player requires SDL2)");
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(feature = "player")]
|
||
fn run_player(video_path: &str, video_uuid: Option<String>) -> Result<()> {
|
||
run_player_with_sdl2(video_path, video_uuid)
|
||
}
|
||
|
||
#[cfg(feature = "player")]
|
||
fn run_player_with_sdl2(video_path: &str, video_uuid: Option<String>) -> Result<()> {
|
||
use sdl2::event::Event;
|
||
use sdl2::keyboard::Keycode;
|
||
use sdl2::pixels::PixelFormatEnum;
|
||
use std::io::{BufReader, Read};
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Arc;
|
||
use std::thread;
|
||
use std::time::{Duration, Instant};
|
||
|
||
println!("\n=== 🎬 SDL2 Video Player ===");
|
||
println!("File: {}", video_path);
|
||
println!("UUID: {:?}", video_uuid);
|
||
|
||
let sdl_context = sdl2::init().map_err(|e| anyhow::anyhow!("SDL init failed: {}", e))?;
|
||
let video_subsystem = sdl_context
|
||
.video()
|
||
.map_err(|e| anyhow::anyhow!("Video init failed: {}", e))?;
|
||
|
||
let width = 1280u32;
|
||
let height = 720u32;
|
||
|
||
let window = video_subsystem
|
||
.window("Momentry Player", width, height)
|
||
.position_centered()
|
||
.resizable()
|
||
.build()
|
||
.map_err(|e| anyhow::anyhow!("Window creation failed: {}", e))?;
|
||
|
||
let mut canvas = window
|
||
.into_canvas()
|
||
.build()
|
||
.map_err(|e| anyhow::anyhow!("Canvas creation failed: {}", e))?;
|
||
|
||
let texture_creator = canvas.texture_creator();
|
||
let mut texture = texture_creator
|
||
.create_texture_streaming(PixelFormatEnum::RGB24, width as u32, height as u32)
|
||
.map_err(|e| anyhow::anyhow!("Texture creation failed: {}", e))?;
|
||
|
||
let ffmpeg_path = if cfg!(target_os = "macos") {
|
||
"/opt/homebrew/bin/ffmpeg"
|
||
} else {
|
||
"ffmpeg"
|
||
};
|
||
|
||
let mut ffmpeg = std::process::Command::new(ffmpeg_path)
|
||
.args([
|
||
"-i",
|
||
video_path,
|
||
"-vf",
|
||
&format!(
|
||
"scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2",
|
||
width, height, width, height
|
||
),
|
||
"-pix_fmt",
|
||
"rgb24",
|
||
"-r",
|
||
"30",
|
||
"-f",
|
||
"rawvideo",
|
||
"-",
|
||
])
|
||
.stdout(std::process::Stdio::piped())
|
||
.stderr(std::process::Stdio::null())
|
||
.spawn()
|
||
.map_err(|e| anyhow::anyhow!("Failed to start ffmpeg: {}", e))?;
|
||
|
||
let stdout = ffmpeg
|
||
.stdout
|
||
.take()
|
||
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
|
||
let mut reader = BufReader::new(stdout);
|
||
|
||
let frame_size = (width * height * 3) as usize;
|
||
let mut frame_buffer = vec![0u8; frame_size];
|
||
|
||
let playing = Arc::new(AtomicBool::new(true));
|
||
let playing_clone = playing.clone();
|
||
|
||
let mut event_pump = sdl_context
|
||
.event_pump()
|
||
.map_err(|e| anyhow::anyhow!("Event pump failed: {}", e))?;
|
||
|
||
let mut asr_overlay = asr_overlay::AsrOverlay::new();
|
||
let _ = asr_overlay.load_from_file(video_path);
|
||
println!("ASR Overlay initialized: {}", !asr_overlay.is_empty());
|
||
|
||
let video_duration = get_video_duration(video_path);
|
||
println!("Video duration: {:.1}s", video_duration);
|
||
|
||
let mut frame_count = 0u64;
|
||
let frame_duration = Duration::from_millis(33);
|
||
let mut paused = false;
|
||
let mut current_time = 0.0;
|
||
let mut seek_request: Option<f64> = None;
|
||
let fps = 30.0;
|
||
|
||
let mut asr_overlay_visible = false;
|
||
|
||
println!("Playing... (Press SPACE to pause, Q/ESC to quit, ←/→ to seek, A to toggle ASR, F for fullscreen)");
|
||
|
||
loop {
|
||
let frame_start = Instant::now();
|
||
|
||
// Handle seek by restarting ffmpeg
|
||
if let Some(seek_pos) = seek_request {
|
||
seek_request = None;
|
||
println!("\n⏩ Seeking to {:.1}s...", seek_pos);
|
||
|
||
// Kill old ffmpeg and restart with seek position
|
||
let _ = ffmpeg.kill();
|
||
|
||
ffmpeg = std::process::Command::new(ffmpeg_path)
|
||
.args([
|
||
"-ss", &format!("{:.2}", seek_pos),
|
||
"-i", video_path,
|
||
"-vf", &format!(
|
||
"scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2",
|
||
width, height, width, height
|
||
),
|
||
"-pix_fmt", "rgb24",
|
||
"-r", "30",
|
||
"-f", "rawvideo",
|
||
"-",
|
||
])
|
||
.stdout(std::process::Stdio::piped())
|
||
.stderr(std::process::Stdio::null())
|
||
.spawn()
|
||
.map_err(|e| anyhow::anyhow!("Failed to restart ffmpeg: {}", e))?;
|
||
|
||
let stdout = ffmpeg
|
||
.stdout
|
||
.take()
|
||
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
|
||
reader = BufReader::new(stdout);
|
||
current_time = seek_pos;
|
||
println!("▶ Resumed at {:.1}s", current_time);
|
||
}
|
||
|
||
for event in event_pump.poll_iter() {
|
||
match event {
|
||
Event::Quit { .. } => {
|
||
println!("\n👋 Quitting player");
|
||
playing_clone.store(false, Ordering::SeqCst);
|
||
break;
|
||
}
|
||
Event::KeyDown { keycode, .. } => match keycode {
|
||
Some(Keycode::Q) | Some(Keycode::Escape) => {
|
||
println!("\n👋 Quitting player");
|
||
playing_clone.store(false, Ordering::SeqCst);
|
||
break;
|
||
}
|
||
Some(Keycode::Space) => {
|
||
paused = !paused;
|
||
println!("{}", if paused { "⏸ Paused" } else { "▶ Playing" });
|
||
}
|
||
Some(Keycode::Left) => {
|
||
let new_time = (current_time - 10.0).max(0.0);
|
||
seek_request = Some(new_time);
|
||
println!("⏪ Seek to {:.1}s", new_time);
|
||
}
|
||
Some(Keycode::Right) => {
|
||
let new_time = current_time + 10.0;
|
||
seek_request = Some(new_time);
|
||
println!("⏩ Seek to {:.1}s", new_time);
|
||
}
|
||
Some(Keycode::Up) => {
|
||
let new_time = (current_time - 60.0).max(0.0);
|
||
seek_request = Some(new_time);
|
||
println!("⏪ Seek to {:.1}s (1min)", new_time);
|
||
}
|
||
Some(Keycode::Down) => {
|
||
let new_time = current_time + 60.0;
|
||
seek_request = Some(new_time);
|
||
println!("⏩ Seek to {:.1}s (+1min)", new_time);
|
||
}
|
||
Some(Keycode::A) => {
|
||
// Toggle ASR Visibility
|
||
asr_overlay_visible = !asr_overlay_visible;
|
||
println!(
|
||
"{}",
|
||
if asr_overlay_visible {
|
||
"🔊 ASR ON"
|
||
} else {
|
||
"🔇 ASR OFF"
|
||
}
|
||
);
|
||
}
|
||
Some(Keycode::F) => {
|
||
println!("📺 Toggle fullscreen (not implemented in basic SDL2)");
|
||
}
|
||
_ => {}
|
||
},
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
if !playing_clone.load(Ordering::SeqCst) {
|
||
break;
|
||
}
|
||
|
||
if paused {
|
||
thread::sleep(Duration::from_millis(100));
|
||
continue;
|
||
}
|
||
|
||
// Update ASR text based on current time
|
||
if !asr_overlay.is_empty() {
|
||
asr_overlay.update(current_time);
|
||
}
|
||
|
||
match reader.read_exact(&mut frame_buffer) {
|
||
Ok(_) => {
|
||
texture
|
||
.update(None, &frame_buffer, (width * 3) as usize)
|
||
.map_err(|e| anyhow::anyhow!("Texture update failed: {}", e))?;
|
||
|
||
// Draw everything
|
||
canvas.clear();
|
||
|
||
canvas
|
||
.copy(&texture, None, None)
|
||
.map_err(|e| anyhow::anyhow!("Render failed: {}", e))?;
|
||
|
||
// Draw ASR Text if visible and available
|
||
if asr_overlay_visible && !asr_overlay.get_text().is_empty() {
|
||
// Placeholder: Cannot use TTF functions directly here without font context.
|
||
// For now, just printing to console to verify timing.
|
||
// In a real implementation, load font and draw text here.
|
||
println!("[ASR] {:.1}s: {}", current_time, asr_overlay.get_text());
|
||
}
|
||
|
||
// Draw progress bar at bottom - gray background, green progress
|
||
use sdl2::rect::Rect;
|
||
let progress = if video_duration > 0.0 {
|
||
(current_time / video_duration).min(1.0)
|
||
} else {
|
||
0.0
|
||
};
|
||
let bar_width = ((width as f64) * progress) as u32;
|
||
|
||
canvas.set_draw_color(sdl2::pixels::Color::RGB(50, 50, 50)); // Background
|
||
let _ = canvas.fill_rect(Rect::new(0, height as i32 - 15, width, 5));
|
||
if bar_width > 0 {
|
||
canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 200, 0)); // Progress
|
||
let _ = canvas.fill_rect(Rect::new(0, height as i32 - 15, bar_width, 5));
|
||
}
|
||
// Reset draw color to black for next frame
|
||
canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 0, 0));
|
||
|
||
canvas.present();
|
||
|
||
frame_count += 1;
|
||
current_time += 1.0 / fps;
|
||
|
||
let elapsed = frame_start.elapsed();
|
||
if elapsed < frame_duration {
|
||
thread::sleep(frame_duration - elapsed);
|
||
}
|
||
}
|
||
Err(_) => {
|
||
println!(
|
||
"\n📽️ End of video ({} frames, {:.1}s)",
|
||
frame_count, current_time
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
let _ = ffmpeg.kill();
|
||
println!("✅ Playback finished (total: {:.1}s)", current_time);
|
||
Ok(())
|
||
}
|
||
|
||
fn run_local_mode(external_player: &str) -> Result<()> {
|
||
let args: Vec<String> = env::args().collect();
|
||
|
||
// Find video path - skip all flags and get the first non-flag argument after them
|
||
let video_path = args
|
||
.iter()
|
||
.skip(1) // Skip binary name
|
||
.skip_while(|a| a.starts_with('-')) // Skip flags
|
||
.next()
|
||
.cloned();
|
||
|
||
let video_path = match video_path {
|
||
Some(p) if !p.is_empty() => p,
|
||
_ => {
|
||
println!("Local Mode - Play local video files");
|
||
println!("=====================================\n");
|
||
print!("Enter video file path: ");
|
||
let mut input = String::new();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let path = input.trim().to_string();
|
||
if path.is_empty() {
|
||
anyhow::bail!("No video path provided");
|
||
}
|
||
path
|
||
}
|
||
};
|
||
|
||
if !Path::new(&video_path).exists() {
|
||
anyhow::bail!("File not found: {}", video_path);
|
||
}
|
||
|
||
println!("\nUsing external player: {}", external_player);
|
||
println!("Playing: {}", video_path);
|
||
|
||
match external_player {
|
||
"vlc" => {
|
||
std::process::Command::new("open")
|
||
.arg("-a")
|
||
.arg("VLC")
|
||
.arg(&video_path)
|
||
.spawn()?;
|
||
println!("✅ Opened with VLC");
|
||
}
|
||
"mpv" => {
|
||
std::process::Command::new("mpv").arg(&video_path).spawn()?;
|
||
println!("✅ Opened with mpv");
|
||
}
|
||
"ffplay" => {
|
||
std::process::Command::new("ffplay")
|
||
.arg("-autoexit")
|
||
.arg(&video_path)
|
||
.spawn()?;
|
||
println!("✅ Opened with ffplay");
|
||
}
|
||
"sdl2" => {
|
||
#[cfg(feature = "player")]
|
||
return run_player_with_sdl2(&video_path, None);
|
||
#[cfg(not(feature = "player"))]
|
||
{
|
||
println!("SDL2 player not enabled. Rebuild with --features player");
|
||
}
|
||
}
|
||
_ => {
|
||
std::process::Command::new(external_player)
|
||
.arg(&video_path)
|
||
.spawn()?;
|
||
println!("✅ Opened with {}", external_player);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn run_online_mode() -> Result<()> {
|
||
println!("\n===========================================");
|
||
println!(" 🎬 Online Mode - Momentry");
|
||
println!("===========================================\n");
|
||
|
||
let client = ApiClient::new();
|
||
println!("Connected to API: {}", client.base_url());
|
||
|
||
let rt = tokio::runtime::Runtime::new()?;
|
||
|
||
loop {
|
||
println!("\n┌─────────────────────────────────────────┐");
|
||
println!("│ Online Mode Menu │");
|
||
println!("├─────────────────────────────────────────┤");
|
||
println!("│ [1] List Videos - 列出所有影片 │");
|
||
println!("│ [2] Search - RAG 搜尋影片內容 │");
|
||
println!("│ [3] Play - 播放影片 │");
|
||
println!("│ [4] Lookup - 查詢影片資訊 │");
|
||
println!("│ [q] Quit - 離開 │");
|
||
println!("└─────────────────────────────────────────┘");
|
||
print!("\n請選擇: ");
|
||
|
||
let mut input = String::new();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let choice = input.trim();
|
||
|
||
match choice {
|
||
"1" => {
|
||
println!("\n=== 📋 影片列表 ===");
|
||
match rt.block_on(client.list_videos()) {
|
||
Ok(videos) => {
|
||
if videos.is_empty() {
|
||
println!("沒有找到任何影片");
|
||
} else {
|
||
println!("\n共 {} 部影片:\n", videos.len());
|
||
for (i, v) in videos.iter().enumerate() {
|
||
let duration = format!(
|
||
"{}:{:02}",
|
||
(v.duration / 60.0) as u32,
|
||
(v.duration % 60.0) as u32
|
||
);
|
||
println!(
|
||
" [{}] {} | {} | {}x{} | {}",
|
||
i + 1,
|
||
v.file_name,
|
||
v.uuid.chars().take(8).collect::<String>(),
|
||
v.width,
|
||
v.height,
|
||
duration
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Err(e) => println!("取得影片列表失敗: {}", e),
|
||
}
|
||
}
|
||
"2" => {
|
||
println!("\n=== 🔍 RAG 搜尋 ===");
|
||
print!("輸入搜尋關鍵字: ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let query = input.trim().to_string();
|
||
if query.is_empty() {
|
||
println!("搜尋關鍵字不能為空");
|
||
continue;
|
||
}
|
||
|
||
print!("限定特定影片?(y/N): ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let limit_uuid = if input.trim().to_lowercase() == "y" {
|
||
print!("輸入影片 UUID: ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
Some(input.trim().to_string())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
println!("\n搜尋中...");
|
||
match rt.block_on(client.search_chunks(&query, limit_uuid.as_deref(), Some(10))) {
|
||
Ok(response) => {
|
||
if response.results.is_empty() {
|
||
println!("沒有找到結果");
|
||
continue;
|
||
}
|
||
println!("\n找到 {} 個結果:\n", response.results.len());
|
||
for (i, r) in response.results.iter().enumerate() {
|
||
let time_range = format!(
|
||
"{:02}:{:02} - {:02}:{:02}",
|
||
(r.start_time / 60.0) as u32,
|
||
(r.start_time % 60.0) as u32,
|
||
(r.end_time / 60.0) as u32,
|
||
(r.end_time % 60.0) as u32
|
||
);
|
||
let text_preview = if r.text.len() > 50 {
|
||
format!("{}...", &r.text[..50])
|
||
} else {
|
||
r.text.clone()
|
||
};
|
||
println!(
|
||
" [{}] {} | {} | {:.2} | {}",
|
||
i + 1,
|
||
time_range,
|
||
r.uuid.chars().take(8).collect::<String>(),
|
||
r.score,
|
||
text_preview
|
||
);
|
||
}
|
||
|
||
let mut current_player: Option<std::process::Child> = None;
|
||
|
||
loop {
|
||
if let Some(ref mut child) = current_player {
|
||
match child.try_wait() {
|
||
Ok(Some(_)) => {
|
||
println!("播放器已結束");
|
||
current_player = None;
|
||
}
|
||
Ok(None) => {
|
||
// 還在執行中
|
||
}
|
||
Err(e) => {
|
||
println!("檢查播放器狀態失敗:{}", e);
|
||
current_player = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
print!(
|
||
"\n選擇播放 (1-{}) 或 q 離開 (kill player), L 重新顯示列表:",
|
||
response.results.len()
|
||
);
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let selection = input.trim();
|
||
let selection_lower = selection.to_lowercase();
|
||
if selection_lower == "q" {
|
||
if let Some(ref mut child) = current_player {
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
println!("已終止播放器");
|
||
current_player = None;
|
||
}
|
||
break;
|
||
}
|
||
if selection_lower == "l" {
|
||
println!("\n搜尋結果:");
|
||
for (i, r) in response.results.iter().enumerate() {
|
||
let time_range = format!(
|
||
"{:02}:{:02} - {:02}:{:02}",
|
||
(r.start_time / 60.0) as u32,
|
||
(r.start_time % 60.0) as u32,
|
||
(r.end_time / 60.0) as u32,
|
||
(r.end_time % 60.0) as u32
|
||
);
|
||
let text_preview = if r.text.len() > 50 {
|
||
format!("{}...", &r.text[..50])
|
||
} else {
|
||
r.text.clone()
|
||
};
|
||
println!(
|
||
" [{}] {} | {} | {:.2} | {}",
|
||
i + 1,
|
||
time_range,
|
||
r.uuid.chars().take(8).collect::<String>(),
|
||
r.score,
|
||
text_preview
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
if let Ok(idx) = selection.parse::<usize>() {
|
||
if idx > 0 && idx <= response.results.len() {
|
||
let selected = &response.results[idx - 1];
|
||
println!("\n播放:{} - {}", selected.uuid, selected.text);
|
||
|
||
if let Some(ref mut child) = current_player {
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
println!("已終止前一個播放器");
|
||
}
|
||
|
||
match rt.block_on(client.lookup_video(&selected.uuid)) {
|
||
Ok(info) => {
|
||
if let Some(path) = &info.file_path {
|
||
if std::path::Path::new(path).exists() {
|
||
let start_sec =
|
||
(selected.start_time as f64) - 2.0;
|
||
let end_sec = (selected.end_time as f64) + 2.0;
|
||
println!(
|
||
"開啟:{} (從 {:.0} 到 {:.0} 秒,A-B 循環)",
|
||
path, start_sec, end_sec
|
||
);
|
||
println!("提示:mpv 視窗中按 c/C 切換循環,q 離開,Space 暫停");
|
||
current_player = Some(
|
||
std::process::Command::new("mpv")
|
||
.arg(format!(
|
||
"--start={:.2}",
|
||
start_sec.max(0.0)
|
||
))
|
||
.arg(format!(
|
||
"--ab-loop-a={:.2}",
|
||
start_sec.max(0.0)
|
||
))
|
||
.arg(format!("--ab-loop-b={:.2}", end_sec))
|
||
.arg("--input-commands=bind c ab-loop; bind C ab-loop")
|
||
.arg(path)
|
||
.spawn()?
|
||
);
|
||
} else {
|
||
println!("錯誤:檔案不存在:{}", path);
|
||
}
|
||
}
|
||
}
|
||
Err(e) => println!("查詢失敗:{}", e),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Err(e) => println!("搜尋失敗:{}", e),
|
||
}
|
||
}
|
||
"4" => {
|
||
println!("\n=== 🔎 查詢影片 ===");
|
||
print!("輸入影片 UUID (直接 Enter 從列表選擇): ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let uuid = input.trim();
|
||
|
||
if uuid.is_empty() {
|
||
println!("載入影片列表...");
|
||
match rt.block_on(client.list_videos()) {
|
||
Ok(videos) => {
|
||
if videos.is_empty() {
|
||
println!("沒有影片");
|
||
continue;
|
||
}
|
||
println!("\n選擇影片:");
|
||
for (i, v) in videos.iter().enumerate() {
|
||
println!(" [{}] {} ({})", i + 1, v.file_name, v.uuid);
|
||
}
|
||
print!("\n選擇編號:");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
if let Ok(idx) = input.trim().parse::<usize>() {
|
||
if idx > 0 && idx <= videos.len() {
|
||
let selected = &videos[idx - 1];
|
||
println!("\n查詢中...");
|
||
match rt.block_on(client.lookup_video(&selected.uuid)) {
|
||
Ok(info) => {
|
||
println!("\n✓ 找到影片:");
|
||
println!(" UUID: {}", info.uuid);
|
||
if let Some(path) = &info.file_path {
|
||
println!(" 路徑:{}", path);
|
||
}
|
||
if let Some(name) = &info.file_name {
|
||
println!(" 名稱:{}", name);
|
||
}
|
||
if let Some(dur) = info.duration {
|
||
println!(" 時長:{:.2}s", dur);
|
||
}
|
||
}
|
||
Err(e) => println!("查詢失敗:{}", e),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Err(e) => println!("取得影片列表失敗:{}", e),
|
||
}
|
||
} else {
|
||
println!("\n查詢中...");
|
||
match rt.block_on(client.lookup_video(uuid)) {
|
||
Ok(info) => {
|
||
println!("\n✓ 找到影片:");
|
||
println!(" UUID: {}", info.uuid);
|
||
if let Some(path) = &info.file_path {
|
||
println!(" 路徑:{}", path);
|
||
}
|
||
if let Some(name) = &info.file_name {
|
||
println!(" 名稱:{}", name);
|
||
}
|
||
if let Some(dur) = info.duration {
|
||
println!(" 時長:{:.2}s", dur);
|
||
}
|
||
}
|
||
Err(e) => println!("查詢失敗:{}", e),
|
||
}
|
||
}
|
||
}
|
||
"3" => {
|
||
println!("\n=== ▶ 播放影片 ===");
|
||
print!("輸入影片 UUID (直接 Enter 從列表選擇): ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let uuid = input.trim();
|
||
|
||
if uuid.is_empty() {
|
||
println!("載入影片列表...");
|
||
match rt.block_on(client.list_videos()) {
|
||
Ok(videos) => {
|
||
if videos.is_empty() {
|
||
println!("沒有影片");
|
||
continue;
|
||
}
|
||
println!("\n選擇影片:");
|
||
for (i, v) in videos.iter().enumerate() {
|
||
println!(" [{}] {} ({})", i + 1, v.file_name, v.uuid);
|
||
}
|
||
print!("\n選擇編號:");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
if let Ok(idx) = input.trim().parse::<usize>() {
|
||
if idx > 0 && idx <= videos.len() {
|
||
let selected = &videos[idx - 1];
|
||
println!("\n播放: {}", selected.file_path);
|
||
if std::path::Path::new(&selected.file_path).exists() {
|
||
std::process::Command::new("mpv")
|
||
.arg(&selected.file_path)
|
||
.spawn()?;
|
||
} else {
|
||
println!("錯誤:檔案不存在:{}", selected.file_path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Err(e) => println!("取得影片列表失敗:{}", e),
|
||
}
|
||
} else {
|
||
match rt.block_on(client.lookup_video(uuid)) {
|
||
Ok(info) => {
|
||
if let Some(path) = &info.file_path {
|
||
println!("開啟: {}", path);
|
||
if std::path::Path::new(path).exists() {
|
||
std::process::Command::new("mpv").arg(path).spawn()?;
|
||
} else {
|
||
println!("錯誤:檔案不存在:{}", path);
|
||
}
|
||
}
|
||
}
|
||
Err(e) => println!("查詢失敗:{}", e),
|
||
}
|
||
}
|
||
}
|
||
"q" | "Q" => {
|
||
println!("\n👋 再見!");
|
||
break;
|
||
}
|
||
_ => {
|
||
println!("無效選項");
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn main() -> Result<()> {
|
||
env_logger::init();
|
||
let args: Vec<String> = env::args().collect();
|
||
|
||
let should_download = args.iter().any(|a| a == "-d" || a == "--download");
|
||
let show_selector = args.iter().any(|a| a == "-s" || a == "--selector");
|
||
let test_api_mode = args.iter().any(|a| a == "-t" || a == "--test-api");
|
||
let local_mode = args.iter().any(|a| a == "-l" || a == "--local");
|
||
let online_mode = args.iter().any(|a| a == "-o" || a == "--online");
|
||
|
||
// Get external player choice
|
||
let external_player = args
|
||
.iter()
|
||
.position(|a| a == "-p" || a == "--player")
|
||
.and_then(|i| args.get(i + 1))
|
||
.cloned()
|
||
.unwrap_or_else(|| "vlc".to_string());
|
||
|
||
// API Testing Mode
|
||
if test_api_mode {
|
||
return run_api_test_mode();
|
||
}
|
||
|
||
// If --selector flag is provided, show video selector (online mode)
|
||
if show_selector {
|
||
return run_selector();
|
||
}
|
||
|
||
// If --online or -o is provided, run online mode
|
||
if online_mode {
|
||
return run_online_mode();
|
||
}
|
||
|
||
// If --local or -l is provided, run local mode with external player
|
||
if local_mode {
|
||
return run_local_mode(&external_player);
|
||
}
|
||
|
||
let video_path = if args.len() < 2 || (should_download && args.len() < 3) {
|
||
println!("Video Player\n============\nEnter video path or YouTube URL:");
|
||
let mut input = String::new();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let trimmed = input.trim().to_string();
|
||
if trimmed.is_empty() {
|
||
anyhow::bail!("No input provided");
|
||
}
|
||
trimmed
|
||
} else {
|
||
let idx = if should_download { 2 } else { 1 };
|
||
args[idx].clone()
|
||
};
|
||
|
||
println!("Video Player\n============\nInput: {}", video_path);
|
||
|
||
let final_path = if should_download && is_youtube_url(&video_path) {
|
||
println!("Downloading with best quality...");
|
||
let config = load_config();
|
||
let format_id = "best[ext=mp4][vcodec!=none]";
|
||
match download_video(&video_path, format_id, &config.download_dir) {
|
||
Ok(local_path) => {
|
||
println!("Download complete: {}", local_path);
|
||
Some(local_path)
|
||
}
|
||
Err(e) => {
|
||
eprintln!("Download failed: {}, playing stream instead", e);
|
||
None
|
||
}
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let play_path = final_path.unwrap_or(video_path);
|
||
|
||
if is_youtube_url(&play_path) {
|
||
println!("Source: YouTube");
|
||
} else if Path::new(&play_path).exists() {
|
||
println!("Source: Local file");
|
||
} else {
|
||
anyhow::bail!("File not found: {}", play_path);
|
||
}
|
||
|
||
let video_uuid = lookup_video_uuid(&play_path);
|
||
println!("Video UUID: {:?}", video_uuid);
|
||
run_player(&play_path, video_uuid)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn run_selector() -> Result<()> {
|
||
use std::process::Command as StdCommand;
|
||
|
||
let _db_url = momentry_core::core::config::DATABASE_URL.as_str();
|
||
|
||
// Use psql to query videos
|
||
let output = StdCommand::new("psql")
|
||
.args(["-U", "accusys", "-h", "localhost", "-d", "momentry", "-t", "-A",
|
||
"-c", "SELECT uuid, file_name, file_path, duration, width, height FROM videos ORDER BY created_at DESC"])
|
||
.output();
|
||
|
||
let videos: Vec<VideoEntry> = match output {
|
||
Ok(out) if out.status.success() => {
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
stdout
|
||
.lines()
|
||
.filter(|line| !line.is_empty())
|
||
.filter_map(|line| {
|
||
let parts: Vec<&str> = line.split('|').collect();
|
||
if parts.len() >= 6 {
|
||
Some(VideoEntry {
|
||
uuid: parts[0].to_string(),
|
||
file_name: parts[1].to_string(),
|
||
file_path: parts[2].to_string(),
|
||
duration: parts[3].parse().unwrap_or(0.0),
|
||
width: parts[4].parse().unwrap_or(0),
|
||
height: parts[5].parse().unwrap_or(0),
|
||
thumbnail_dir: None,
|
||
})
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
_ => {
|
||
// Fallback: scan directory
|
||
println!("Could not connect to database, scanning directory...");
|
||
let test_video_dir = PathBuf::from("/Users/accusys/momentry_core_project/test_video");
|
||
std::fs::read_dir(&test_video_dir)?
|
||
.filter_map(|e| e.ok())
|
||
.filter(|e| {
|
||
let path = e.path();
|
||
matches!(
|
||
path.extension().and_then(|s| s.to_str()),
|
||
Some("mp4") | Some("mov") | Some("m4v") | Some("avi")
|
||
)
|
||
})
|
||
.map(|e| {
|
||
let path = e.path();
|
||
VideoEntry {
|
||
uuid: format!("{:x}", md5::compute(path.to_string_lossy().as_bytes()))
|
||
[0..16]
|
||
.to_string(),
|
||
file_name: path
|
||
.file_name()
|
||
.unwrap_or_default()
|
||
.to_string_lossy()
|
||
.to_string(),
|
||
file_path: path.to_string_lossy().to_string(),
|
||
duration: 0.0,
|
||
width: 0,
|
||
height: 0,
|
||
thumbnail_dir: None,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
};
|
||
|
||
if videos.is_empty() {
|
||
println!("No videos found. Register videos first with 'momentry register'");
|
||
return Ok(());
|
||
}
|
||
|
||
// Try interactive mode, fall back to list if not a terminal
|
||
if atty::is(atty::Stream::Stdout) {
|
||
println!("Found {} videos", videos.len());
|
||
|
||
let mut selector = VideoSelector::new(videos);
|
||
match selector.run() {
|
||
Ok(Some(video)) => {
|
||
println!("\nPlaying: {} ({})", video.file_name, video.uuid);
|
||
run_player(&video.file_path, Some(video.uuid))?;
|
||
}
|
||
Ok(None) => {
|
||
println!("\nNo video selected");
|
||
}
|
||
Err(e) => {
|
||
eprintln!("Selector error: {}", e);
|
||
}
|
||
}
|
||
} else {
|
||
// Non-interactive: show list
|
||
println!("\nAvailable videos:");
|
||
for (i, video) in videos.iter().enumerate() {
|
||
println!(
|
||
" {}) {} - {} ({})",
|
||
i + 1,
|
||
video.file_name,
|
||
video.format_duration(),
|
||
video.uuid
|
||
);
|
||
}
|
||
println!("\nRun with a video path to play, or use interactive mode in a terminal.");
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn run_api_test_mode() -> Result<()> {
|
||
println!("\n===========================================");
|
||
println!(" 🎬 API Testing GUI");
|
||
println!("===========================================\n");
|
||
|
||
let client = ApiClient::new();
|
||
println!("API Server: {}\n", client.base_url());
|
||
|
||
println!(
|
||
"Waiting for API server... (make sure 'cargo run --bin momentry -- server' is running)\n"
|
||
);
|
||
|
||
let rt = tokio::runtime::Runtime::new()?;
|
||
|
||
loop {
|
||
println!("\n┌─────────────────────────────────────────┐");
|
||
println!("│ Main Menu │");
|
||
println!("├─────────────────────────────────────────┤");
|
||
println!("│ [1] Search - 自然語言搜尋影片內容 │");
|
||
println!("│ [2] List - 列出所有影片 │");
|
||
println!("│ [3] Register - 註冊新影片 │");
|
||
println!("│ [4] Lookup - 查詢影片資訊 │");
|
||
println!("│ [5] Play - 播放影片 │");
|
||
println!("│ [q] Quit - 離開 │");
|
||
println!("└─────────────────────────────────────────┘");
|
||
print!("\n請選擇: ");
|
||
|
||
let mut input = String::new();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let choice = input.trim();
|
||
|
||
match choice {
|
||
"1" => {
|
||
println!("\n=== 🔍 自然語言搜尋 ===");
|
||
print!("輸入搜尋關鍵字: ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let query = input.trim().to_string();
|
||
if query.is_empty() {
|
||
println!("搜尋關鍵字不能為空");
|
||
continue;
|
||
}
|
||
|
||
print!("是否限定特定影片?(y/N): ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let limit_uuid = if input.trim().to_lowercase() == "y" {
|
||
print!("輸入影片 UUID: ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
Some(input.trim().to_string())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
println!("\n搜尋中...");
|
||
match rt.block_on(client.search_chunks(&query, limit_uuid.as_deref(), Some(20))) {
|
||
Ok(response) => {
|
||
println!("\n找到 {} 個結果:\n", response.results.len());
|
||
for (i, r) in response.results.iter().enumerate() {
|
||
let time_range = format!(
|
||
"{:02}:{:02} - {:02}:{:02}",
|
||
(r.start_time / 60.0) as u32,
|
||
(r.start_time % 60.0) as u32,
|
||
(r.end_time / 60.0) as u32,
|
||
(r.end_time % 60.0) as u32
|
||
);
|
||
let text_preview = if r.text.len() > 60 {
|
||
format!("{}...", &r.text[..60])
|
||
} else {
|
||
r.text.clone()
|
||
};
|
||
println!(
|
||
" [{}] {} | {} | {:.2} | {}",
|
||
i + 1,
|
||
time_range,
|
||
r.uuid.chars().take(8).collect::<String>(),
|
||
r.score,
|
||
text_preview
|
||
);
|
||
}
|
||
|
||
if !response.results.is_empty() {
|
||
print!("\n選擇要播放的結果編號 (直接Enter跳過): ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
if let Ok(idx) = input.trim().parse::<usize>() {
|
||
if idx > 0 && idx <= response.results.len() {
|
||
let selected = &response.results[idx - 1];
|
||
println!("\n正在取得影片路徑...");
|
||
match rt.block_on(client.lookup_video(&selected.uuid)) {
|
||
Ok(info) => {
|
||
if let Some(path) = &info.file_path {
|
||
println!(
|
||
"播放: {} @ {:.2}s",
|
||
path, selected.start_time
|
||
);
|
||
let video_uuid = Some(selected.uuid.clone());
|
||
run_player(path, video_uuid)?;
|
||
} else {
|
||
println!("無法取得影片路徑");
|
||
}
|
||
}
|
||
Err(e) => println!("查詢失敗: {}", e),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Err(e) => println!("搜尋失敗: {}", e),
|
||
}
|
||
}
|
||
"2" => {
|
||
println!("\n=== 📋 影片列表 ===");
|
||
println!("載入中...");
|
||
match rt.block_on(client.list_videos()) {
|
||
Ok(videos) => {
|
||
if videos.is_empty() {
|
||
println!("沒有找到任何影片,請先註冊");
|
||
} else {
|
||
println!("\n共 {} 部影片:\n", videos.len());
|
||
for (i, v) in videos.iter().enumerate() {
|
||
let duration = format!(
|
||
"{}:{:02}",
|
||
(v.duration / 60.0) as u32,
|
||
(v.duration % 60.0) as u32
|
||
);
|
||
println!(
|
||
" [{}] {} | {} | {}x{} | {}",
|
||
i + 1,
|
||
v.file_name,
|
||
v.uuid.chars().take(8).collect::<String>(),
|
||
v.width,
|
||
v.height,
|
||
duration
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Err(e) => println!("取得影片列表失敗: {}", e),
|
||
}
|
||
}
|
||
"3" => {
|
||
println!("\n=== 📝 註冊影片 ===");
|
||
print!("輸入影片檔案路徑 (直接Enter使用自動搜尋): ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let path = input.trim();
|
||
|
||
let video_path = if path.is_empty() {
|
||
println!("自動搜尋影片...");
|
||
match api_client::find_video_path() {
|
||
Some(p) => {
|
||
println!("找到: {}", p);
|
||
p
|
||
}
|
||
None => {
|
||
println!("找不到影片檔案,請手動輸入路徑");
|
||
continue;
|
||
}
|
||
}
|
||
} else {
|
||
path.to_string()
|
||
};
|
||
|
||
if !std::path::Path::new(&video_path).exists() {
|
||
println!("檔案不存在: {}", video_path);
|
||
continue;
|
||
}
|
||
|
||
println!("\n註冊中...");
|
||
match rt.block_on(client.register_video(&video_path)) {
|
||
Ok(resp) => {
|
||
println!("\n✓ 註冊成功!");
|
||
println!(" UUID: {}", resp.uuid);
|
||
println!(" 名稱: {}", resp.file_name);
|
||
println!(" 時長: {:.2}s", resp.duration);
|
||
println!(" 解析度: {}x{}", resp.width, resp.height);
|
||
}
|
||
Err(e) => println!("註冊失敗: {}", e),
|
||
}
|
||
}
|
||
"4" => {
|
||
print!("\n=== 🔎 查詢影片 ===\n輸入影片 UUID: ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let uuid = input.trim();
|
||
if uuid.is_empty() {
|
||
println!("UUID 不能為空");
|
||
continue;
|
||
}
|
||
|
||
println!("\n查詢中...");
|
||
match rt.block_on(client.lookup_video(uuid)) {
|
||
Ok(info) => {
|
||
println!("\n✓ 找到影片:");
|
||
println!(" UUID: {}", info.uuid);
|
||
if let Some(path) = &info.file_path {
|
||
println!(" 路徑: {}", path);
|
||
}
|
||
if let Some(name) = &info.file_name {
|
||
println!(" 名稱: {}", name);
|
||
}
|
||
if let Some(dur) = info.duration {
|
||
println!(" 時長: {:.2}s", dur);
|
||
}
|
||
}
|
||
Err(e) => println!("查詢失敗: {}", e),
|
||
}
|
||
}
|
||
"5" => {
|
||
println!("\n=== ▶ 播放影片 ===");
|
||
print!("輸入影片 UUID (直接Enter從列表選擇): ");
|
||
input.clear();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
let uuid = input.trim();
|
||
|
||
let video_path = if uuid.is_empty() {
|
||
println!("載入影片列表...");
|
||
match rt.block_on(client.list_videos()) {
|
||
Ok(videos) => {
|
||
if videos.is_empty() {
|
||
println!("沒有影片");
|
||
continue;
|
||
}
|
||
let entries: Vec<VideoEntry> = videos
|
||
.into_iter()
|
||
.map(|v| VideoEntry {
|
||
uuid: v.uuid,
|
||
file_name: v.file_name,
|
||
file_path: v.file_path,
|
||
duration: v.duration,
|
||
width: v.width,
|
||
height: v.height,
|
||
thumbnail_dir: None,
|
||
})
|
||
.collect();
|
||
|
||
let mut selector = VideoSelector::new(entries);
|
||
match selector.run() {
|
||
Ok(Some(video)) => video.file_path,
|
||
Ok(None) => {
|
||
println!("取消選擇");
|
||
continue;
|
||
}
|
||
Err(e) => {
|
||
println!("選擇錯誤: {}", e);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
println!("取得列表失敗: {}", e);
|
||
continue;
|
||
}
|
||
}
|
||
} else {
|
||
match rt.block_on(client.lookup_video(uuid)) {
|
||
Ok(info) => {
|
||
if let Some(path) = info.file_path {
|
||
path
|
||
} else {
|
||
println!("找不到影片路徑");
|
||
continue;
|
||
}
|
||
}
|
||
Err(e) => {
|
||
println!("查詢失敗: {}", e);
|
||
continue;
|
||
}
|
||
}
|
||
};
|
||
|
||
let video_uuid = if let Ok(info) = rt.block_on(client.lookup_video(&video_path)) {
|
||
Some(info.uuid)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
println!("\n播放: {}", video_path);
|
||
run_player(&video_path, video_uuid)?;
|
||
}
|
||
"q" | "Q" => {
|
||
println!("\n再見!");
|
||
break;
|
||
}
|
||
_ => {
|
||
println!("無效選項,請重新選擇");
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|