Add VideoWall feature: server-side preview clip generation and mobile grid view

Backend (Rust/Actix-web):
- Add video_preview_clips table and PreviewDao for tracking preview generation
- Add ffmpeg preview clip generator: 10 equally-spaced 1s segments at 480p with CUDA NVENC auto-detection
- Add PreviewClipGenerator actor with semaphore-limited concurrent processing
- Add GET /video/preview and POST /video/preview/status endpoints
- Extend file watcher to detect and queue previews for new videos
- Use relative paths consistently for DB storage (matching EXIF convention)

Frontend (React Native/Expo):
- Add VideoWall grid view with 2-3 column layout of looping preview clips
- Add VideoWallItem component with ActiveVideoPlayer sub-component for lifecycle management
- Add useVideoWall hook for batch status polling with 5s refresh
- Add navigation button in grid header (visible when videos exist)
- Use TextureView surface type to fix Android z-ordering issues
- Optimize memory: players only mount while visible via FlatList windowSize
- Configure ExoPlayer buffer options and caching for short clips
- Tap to toggle audio focus, long press to open in full viewer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-02-25 19:40:17 -05:00
parent 7a0da1ab4a
commit 19c099360e
19 changed files with 1691 additions and 12 deletions

View File

@@ -4,7 +4,10 @@ use crate::database::{
SqliteCalendarEventDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao,
SqliteLocationHistoryDao, SqliteSearchHistoryDao,
};
use crate::video::actors::{PlaylistGenerator, StreamActor, VideoPlaylistManager};
use crate::database::{PreviewDao, SqlitePreviewDao};
use crate::video::actors::{
PlaylistGenerator, PreviewClipGenerator, StreamActor, VideoPlaylistManager,
};
use actix::{Actor, Addr};
use std::env;
use std::sync::{Arc, Mutex};
@@ -12,10 +15,12 @@ use std::sync::{Arc, Mutex};
pub struct AppState {
pub stream_manager: Arc<Addr<StreamActor>>,
pub playlist_manager: Arc<Addr<VideoPlaylistManager>>,
pub preview_clip_generator: Arc<Addr<PreviewClipGenerator>>,
pub base_path: String,
pub thumbnail_path: String,
pub video_path: String,
pub gif_path: String,
pub preview_clips_path: String,
pub excluded_dirs: Vec<String>,
pub ollama: OllamaClient,
pub sms_client: SmsApiClient,
@@ -29,22 +34,32 @@ impl AppState {
thumbnail_path: String,
video_path: String,
gif_path: String,
preview_clips_path: String,
excluded_dirs: Vec<String>,
ollama: OllamaClient,
sms_client: SmsApiClient,
insight_generator: InsightGenerator,
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
) -> Self {
let playlist_generator = PlaylistGenerator::new();
let video_playlist_manager =
VideoPlaylistManager::new(video_path.clone(), playlist_generator.start());
let preview_clip_generator = PreviewClipGenerator::new(
preview_clips_path.clone(),
base_path.clone(),
preview_dao,
);
Self {
stream_manager,
playlist_manager: Arc::new(video_playlist_manager.start()),
preview_clip_generator: Arc::new(preview_clip_generator.start()),
base_path,
thumbnail_path,
video_path,
gif_path,
preview_clips_path,
excluded_dirs,
ollama,
sms_client,
@@ -94,6 +109,8 @@ impl Default for AppState {
Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
let daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>> =
Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new())));
let preview_dao: Arc<Mutex<Box<dyn PreviewDao>>> =
Arc::new(Mutex::new(Box::new(SqlitePreviewDao::new())));
// Initialize Google Takeout DAOs
let calendar_dao: Arc<Mutex<Box<dyn CalendarEventDao>>> =
@@ -119,16 +136,23 @@ impl Default for AppState {
base_path.clone(),
);
// Ensure preview clips directory exists
let preview_clips_path = env::var("PREVIEW_CLIPS_DIRECTORY")
.unwrap_or_else(|_| "preview_clips".to_string());
std::fs::create_dir_all(&preview_clips_path).expect("Failed to create PREVIEW_CLIPS_DIRECTORY");
Self::new(
Arc::new(StreamActor {}.start()),
base_path,
env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"),
env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"),
env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"),
preview_clips_path,
Self::parse_excluded_dirs(),
ollama,
sms_client,
insight_generator,
preview_dao,
)
}
}
@@ -142,10 +166,11 @@ impl AppState {
let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
let base_path = temp_dir.path().to_path_buf();
// Create subdirectories for thumbnails, videos, and gifs
// Create subdirectories for thumbnails, videos, gifs, and preview clips
let thumbnail_path = create_test_subdir(&base_path, "thumbnails");
let video_path = create_test_subdir(&base_path, "videos");
let gif_path = create_test_subdir(&base_path, "gifs");
let preview_clips_path = create_test_subdir(&base_path, "preview_clips");
// Initialize test AI clients
let ollama = OllamaClient::new(
@@ -186,6 +211,10 @@ impl AppState {
base_path_str.clone(),
);
// Initialize test preview DAO
let preview_dao: Arc<Mutex<Box<dyn PreviewDao>>> =
Arc::new(Mutex::new(Box::new(SqlitePreviewDao::new())));
// Create the AppState with the temporary paths
AppState::new(
Arc::new(StreamActor {}.start()),
@@ -193,10 +222,12 @@ impl AppState {
thumbnail_path.to_string_lossy().to_string(),
video_path.to_string_lossy().to_string(),
gif_path.to_string_lossy().to_string(),
preview_clips_path.to_string_lossy().to_string(),
Vec::new(), // No excluded directories for test state
ollama,
sms_client,
insight_generator,
preview_dao,
)
}
}