use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway}; use crate::adapters::filesystem::file_picker::FilePickerAdapter; use crate::application::use_cases::{ add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle, edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack, load_media_info::LoadMediaInfo, remove_track::RemoveTrack, }; use crate::domain::entities::{MkvOutput, Project, VideoFile}; use crate::domain::value_objects::FilePath; use crate::infrastructure::process::run_ffmpeg_async; use crate::ui::components::{ add_audio_track_form::AddAudioTrackForm, add_subtitle_form::AddSubtitleForm, added_track_list::AddedTrackList, batch_panel::{BatchItem, BatchPanel, BatchPanelEvent}, execution_panel::{ExecutionPanel, ExecutionState}, existing_track_list::ExistingTrackList, output_selector::OutputSelector, video_selector::VideoSelector, }; use eframe::egui; use std::sync::mpsc; /// Aba ativa da interface. #[derive(PartialEq)] enum ActiveTab { Single, Batch, } /// Mensagens enviadas do background thread para a UI. enum BackgroundMsg { LogLine(String), Done, Error(String), } /// Estado da aplicação — contém o `Project` como única fonte de verdade. pub struct App { project: Option, pending_source: Option, pending_output: Option, // Componentes de UI com estado próprio video_selector: VideoSelector, output_selector: OutputSelector, existing_track_list: ExistingTrackList, added_track_list: AddedTrackList, add_audio_form: AddAudioTrackForm, add_subtitle_form: AddSubtitleForm, execution_panel: ExecutionPanel, // Canal de comunicação do background thread bg_rx: Option>, // Sender para cancelar a geração em andamento cancel_tx: Option>, // Mensagem de erro global (ex: dependências ausentes) global_error: Option, // Mensagem de feedback de persistência (sucesso ou erro) session_msg: Option<(String, bool)>, // (texto, é_erro) // Aba ativa (Projeto Único ou Lote) active_tab: ActiveTab, // Itens do carrinho de lote batch_items: Vec, // Painel de modo lote com estado próprio batch_panel: BatchPanel, // Índice do item sendo processado no lote (None = nenhum) batch_processing_index: Option, } impl App { pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { // Verifica dependências na inicialização let global_error = crate::infrastructure::process::validate_dependencies() .err() .map(|e| e.to_string()); // Notifica o usuário se existe sessão salva ao iniciar let session_msg = if crate::infrastructure::persistence::session_exists() { Some(( "Sessão anterior encontrada. Clique em \"Carregar sessão\" para restaurar." .to_string(), false, )) } else { None }; App { project: None, pending_source: None, pending_output: None, video_selector: VideoSelector::new(), output_selector: OutputSelector::new(), existing_track_list: ExistingTrackList::new(), added_track_list: AddedTrackList::new(), add_audio_form: AddAudioTrackForm::new(), add_subtitle_form: AddSubtitleForm::new(), execution_panel: ExecutionPanel::new(), bg_rx: None, cancel_tx: None, global_error, session_msg, active_tab: ActiveTab::Single, batch_items: Vec::new(), batch_panel: BatchPanel::new(), batch_processing_index: None, } } /// Tenta construir ou atualizar o Project quando source e output estão disponíveis. fn try_build_project(&mut self) { let source = match &self.pending_source { Some(p) => p.clone(), None => return, }; let output = match &self.pending_output { Some(p) => p.clone(), None => return, }; match Project::new(VideoFile::new(source.clone()), MkvOutput::new(output)) { Ok(mut project) => { // Carrega faixas existentes via ffprobe let probe = FfprobeGateway; match LoadMediaInfo::execute(&mut project, &probe) { Ok(_) => { self.existing_track_list .sync_tracks(&project.existing_tracks); } Err(e) => { self.execution_panel.state = ExecutionState::Error(e.to_string()); } } self.project = Some(project); } Err(e) => { self.execution_panel.state = ExecutionState::Error(e.to_string()); } } } /// Processa mensagens pendentes do background thread. /// Em modo lote, atualiza o item em execução e avança para o próximo automaticamente. fn poll_background(&mut self, ctx: &egui::Context) { // Resultado do término: Ok ou Err(mensagem) enum DoneResult { Ok, Err(String), } let mut done_result: Option = None; if let Some(rx) = &self.bg_rx { loop { match rx.try_recv() { Ok(BackgroundMsg::LogLine(line)) => { if let Some(idx) = self.batch_processing_index { let item = &mut self.batch_items[idx]; item.log_lines.push(line); if item.log_lines.len() > 200 { item.log_lines.remove(0); } } else { self.execution_panel.add_log(line); } } Ok(BackgroundMsg::Done) => { done_result = Some(DoneResult::Ok); break; } Ok(BackgroundMsg::Error(e)) => { done_result = Some(DoneResult::Err(e)); break; } Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { done_result = Some(DoneResult::Ok); break; } } } } if let Some(result) = done_result { self.bg_rx = None; if let Some(idx) = self.batch_processing_index.take() { // Modo lote: atualiza estado do item concluído match result { DoneResult::Ok => { self.batch_items[idx].state = ExecutionState::Success; } DoneResult::Err(e) => { self.batch_items[idx].state = ExecutionState::Error(e); } } // Avança automaticamente para o próximo item aguardando let next = self .batch_items .iter() .position(|item| matches!(item.state, ExecutionState::Idle)); if let Some(next_idx) = next { self.start_batch_item(next_idx, ctx.clone()); } } else { // Modo único match result { DoneResult::Ok => { self.execution_panel.state = ExecutionState::Success; } DoneResult::Err(e) => { self.execution_panel.state = ExecutionState::Error(e); } } } } } /// Inicia geração do MKV em background thread com progresso em tempo real. fn start_generation(&mut self, ctx: egui::Context) { let project = match &self.project { Some(p) => p.clone(), None => return, }; let args = FfmpegCommandBuilder::build(&project); let (tx, rx) = mpsc::channel::(); self.bg_rx = Some(rx); self.execution_panel.state = ExecutionState::Running; self.execution_panel.log_lines.clear(); let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); self.cancel_tx = Some(cancel_tx); std::thread::spawn(move || { // Canal std para receber linhas de log do run_ffmpeg_async let (log_tx, log_rx) = std::sync::mpsc::channel::(); // Thread auxiliar: encaminha cada linha de log para o canal da UI let fwd_tx = tx.clone(); let fwd_ctx = ctx.clone(); let fwd_handle = std::thread::spawn(move || { for line in log_rx { let _ = fwd_tx.send(BackgroundMsg::LogLine(line)); fwd_ctx.request_repaint(); } }); let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio"); let result = rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx)); // Aguarda o encaminhador consumir todas as linhas pendentes // antes de enviar Done/Error, garantindo ordem correta no log let _ = fwd_handle.join(); match result { Ok(_) => { let _ = tx.send(BackgroundMsg::Done); } Err(e) => { let _ = tx.send(BackgroundMsg::Error(e.to_string())); } } ctx.request_repaint(); }); } /// Cancela a geração em andamento (modo projeto único), encerrando o processo FFmpeg. fn cancel_generation(&mut self) { if let Some(tx) = self.cancel_tx.take() { let _ = tx.send(()); } self.bg_rx = None; self.execution_panel.state = ExecutionState::Cancelled; } /// Inicia o processamento de um item específico do carrinho de lote. fn start_batch_item(&mut self, index: usize, ctx: egui::Context) { let project = self.batch_items[index].project.clone(); self.batch_items[index].state = ExecutionState::Running; self.batch_items[index].log_lines.clear(); self.batch_processing_index = Some(index); let args = FfmpegCommandBuilder::build(&project); let (tx, rx) = mpsc::channel::(); self.bg_rx = Some(rx); let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); self.cancel_tx = Some(cancel_tx); std::thread::spawn(move || { let (log_tx, log_rx) = std::sync::mpsc::channel::(); let fwd_tx = tx.clone(); let fwd_ctx = ctx.clone(); let fwd_handle = std::thread::spawn(move || { for line in log_rx { let _ = fwd_tx.send(BackgroundMsg::LogLine(line)); fwd_ctx.request_repaint(); } }); let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio"); let result = rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx)); let _ = fwd_handle.join(); match result { Ok(_) => { let _ = tx.send(BackgroundMsg::Done); } Err(e) => { let _ = tx.send(BackgroundMsg::Error(e.to_string())); } } ctx.request_repaint(); }); } /// Cancela o item atual do lote, mantendo os demais como Aguardando. fn cancel_batch_current(&mut self) { if let Some(tx) = self.cancel_tx.take() { let _ = tx.send(()); } self.bg_rx = None; if let Some(idx) = self.batch_processing_index.take() { self.batch_items[idx].state = ExecutionState::Cancelled; } } } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Consome mensagens assíncronas self.poll_background(ctx); if self.bg_rx.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // ── Cabeçalho (fixo no topo) ────────────────────────────────────────── let header_height = 42.0 + if self.global_error.is_some() { 22.0 } else { 0.0 } + if self.session_msg.is_some() { 22.0 } else { 0.0 }; egui::TopBottomPanel::top("header_panel") .exact_height(header_height) .show(ctx, |ui| { ui.add_space(4.0); ui.horizontal(|ui| { ui.heading("Editor de Faixas de Mídia"); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { // Botão "Salvar sessão" let can_save = self.project.is_some() || !self.batch_items.is_empty(); if ui .add_enabled( can_save, egui::Button::new("💾 Salvar sessão"), ) .on_disabled_hover_text( "Abra um projeto ou adicione itens ao lote para salvar a sessão.", ) .clicked() { use crate::infrastructure::persistence::SessionData; let data = SessionData { single_project: self.project.clone(), batch_projects: self .batch_items .iter() .map(|item| item.project.clone()) .collect(), }; match crate::infrastructure::persistence::save_session(&data) { Ok(_) => { self.session_msg = Some(( "Sessão salva com sucesso.".to_string(), false, )); } Err(e) => { self.session_msg = Some((format!("Erro ao salvar: {}", e), true)); } } } // Botão "Carregar sessão" let can_load = crate::infrastructure::persistence::session_exists(); if ui .add_enabled( can_load, egui::Button::new("📂 Carregar sessão"), ) .on_disabled_hover_text("Nenhuma sessão salva encontrada.") .clicked() { match crate::infrastructure::persistence::load_session() { Ok(data) => { let probe = FfprobeGateway; // Restaura projeto único if let Some(mut project) = data.single_project { // Re-executa ffprobe para garantir que existing_tracks // está atualizado independentemente do que foi salvo. let _ = LoadMediaInfo::execute(&mut project, &probe); self.pending_source = Some(project.source.path.clone()); self.pending_output = Some(project.output.path.clone()); self.existing_track_list .sync_tracks(&project.existing_tracks); self.project = Some(project); } // Restaura itens do lote (estado resetado para Idle) // Re-executa ffprobe em cada projeto para popular // existing_tracks que podem estar vazios na sessão salva. if !data.batch_projects.is_empty() { use crate::ui::components::batch_panel::BatchItem; self.batch_items = data .batch_projects .into_iter() .map(|mut project| { let _ = LoadMediaInfo::execute( &mut project, &probe, ); BatchItem::new(project) }) .collect(); } let single_ok = self.project.is_some(); let batch_count = self.batch_items.len(); self.session_msg = Some(( match (single_ok, batch_count) { (true, 0) => "Sessão carregada com sucesso (projeto único).".to_string(), (false, n) => format!("Sessão carregada com sucesso ({} ite{} no lote).", n, if n == 1 { "m" } else { "ns" }), (true, n) => format!("Sessão carregada com sucesso (projeto único + {} ite{} no lote).", n, if n == 1 { "m" } else { "ns" }), }, false, )); } Err(e) => { self.session_msg = Some(( format!("Erro ao carregar sessão: {}", e), true, )); } } } }, ); }); if let Some(ref err) = self.global_error { ui.colored_label( egui::Color32::RED, format!("⚠ Dependência ausente: {}", err), ); } if let Some((msg, is_err)) = self.session_msg.clone() { let color = if is_err { egui::Color32::RED } else { egui::Color32::from_rgb(0, 180, 80) }; ui.horizontal(|ui| { ui.colored_label(color, &msg); if ui.small_button("✕").clicked() { self.session_msg = None; } }); } }); // ── Barra de abas ───────────────────────────────────────────────────── egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| { ui.horizontal(|ui| { ui.selectable_value(&mut self.active_tab, ActiveTab::Single, "Projeto Único"); ui.selectable_value(&mut self.active_tab, ActiveTab::Batch, "Lote"); }); }); // ── Painel de execução (fixo no rodapé, apenas modo Projeto Único) ───── if self.active_tab == ActiveTab::Single { egui::TopBottomPanel::bottom("execution_panel") .resizable(false) .show(ctx, |ui| { ui.add_space(4.0); let can_generate = self.pending_source.is_some() && self.pending_output.is_some(); let (generate, cancel) = self.execution_panel.ui(ui, can_generate); if generate { let ctx_clone = ctx.clone(); self.start_generation(ctx_clone); } if cancel { self.cancel_generation(); } ui.add_space(4.0); }); } // ── Barra lateral esquerda (apenas modo Projeto Único) ───────────────── if self.active_tab == ActiveTab::Single { egui::SidePanel::left("sidebar_panel") .min_width(220.0) .max_width(400.0) .resizable(true) .show(ctx, |ui| { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { ui.add_space(8.0); // ── Seleção de vídeo ────────────────────────────────── if let Some(path) = self.video_selector.ui(ui) { self.pending_source = Some(path); if self.pending_output.is_some() { self.try_build_project(); } } ui.add_space(8.0); // ── Seleção de saída ────────────────────────────────── // Passa o stem do vídeo para pré-preencher o nome sugerido let source_stem: Option<&str> = self .pending_source .as_ref() .and_then(|p| p.as_path().file_stem()) .and_then(|s| s.to_str()); if let Some(path) = self.output_selector.ui(ui, source_stem) { self.pending_output = Some(path); if self.pending_source.is_some() { self.try_build_project(); } } if self.project.is_none() { ui.add_space(16.0); ui.separator(); ui.add_space(8.0); ui.label( egui::RichText::new( "Selecione o arquivo de vídeo e o destino \ de saída para começar.", ) .italics() .weak(), ); } ui.add_space(8.0); }); }); } // fim da barra lateral (modo Projeto Único) // ── Painel central: faixas / formulários ou painel de lote ─────────── egui::CentralPanel::default().show(ctx, |ui| { // Modo Lote if self.active_tab == ActiveTab::Batch { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { let is_processing = self.batch_processing_index.is_some(); let events = self.batch_panel.ui(ui, &self.batch_items, is_processing); for event in events { match event { BatchPanelEvent::AddItem(mut project) => { let probe = FfprobeGateway; let _ = LoadMediaInfo::execute(&mut project, &probe); self.batch_items.push(BatchItem::new(project)); } BatchPanelEvent::EditItem(idx, mut project) => { let probe = FfprobeGateway; let _ = LoadMediaInfo::execute(&mut project, &probe); if let Some(item) = self.batch_items.get_mut(idx) { item.project = project; item.state = ExecutionState::Idle; item.log_lines.clear(); } } BatchPanelEvent::RemoveItem(idx) => { self.batch_items.remove(idx); } BatchPanelEvent::ProcessAll => { if let Some(idx) = self .batch_items .iter() .position(|item| matches!(item.state, ExecutionState::Idle)) { self.start_batch_item(idx, ctx.clone()); } } BatchPanelEvent::CancelCurrent => { self.cancel_batch_current(); } } } }); return; } // Modo Projeto Único egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { if let Some(project) = &mut self.project { // ── Faixas existentes ───────────────────────────────── let events = self.existing_track_list.ui(ui, &project.existing_tracks); for event in events { use crate::domain::entities::TrackKind; use crate::ui::components::existing_track_list::ExistingTrackEvent; match event { ExistingTrackEvent::OffsetChanged(id, offset) => { let _ = EditExistingTrackSync::execute(project, id, offset); } ExistingTrackEvent::ExportRequested(id) => { if let Some(track) = project.existing_tracks.iter().find(|t| t.id == id).cloned() { // Stem do arquivo de vídeo base para nomear o arquivo exportado let base_stem = project .source .path .as_path() .file_stem() .and_then(|s| s.to_str()) .unwrap_or("track"); let maybe_output = match track.kind { TrackKind::Audio => FilePickerAdapter::save_audio( base_stem, &track.codec, ), TrackKind::Subtitle => { FilePickerAdapter::save_subtitle( base_stem, &track.codec, ) } _ => None, }; if let Some(output_path) = maybe_output { let gateway = FfmpegGateway; match ExportTrack::execute( &project.source.path, &track, &output_path, &gateway, ) { Ok(_) => { self.execution_panel.state = ExecutionState::Success; self.execution_panel.add_log(format!( "Faixa exportada para: {}", output_path.to_string_lossy() )); } Err(e) => { self.execution_panel.state = ExecutionState::Error(e.to_string()); } } } } } } } ui.add_space(8.0); // ── Formulários: lado a lado se largura >= 600 px ───── // Executa o render fora do bloco de borrow de `project` // e coleta as requisições para aplicar em seguida. let wide = ui.available_width() >= 600.0; let (audio_req, subtitle_req) = if wide { // Renderiza dois painéis horizontais de largura igual let available = ui.available_width(); let col_w = (available - ui.spacing().item_spacing.x) / 2.0; let audio_req = ui .allocate_ui_with_layout( egui::Vec2::new(col_w, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| self.add_audio_form.ui(ui), ) .inner; let subtitle_req = ui .allocate_ui_with_layout( egui::Vec2::new(col_w, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| self.add_subtitle_form.ui(ui), ) .inner; (audio_req, subtitle_req) } else { let ar = self.add_audio_form.ui(ui); ui.add_space(4.0); let sr = self.add_subtitle_form.ui(ui); (ar, sr) }; if let Some(req) = audio_req { let _ = AddAudioTrack::execute(project, req.path, req.offset, req.language); } if let Some(req) = subtitle_req { let _ = AddSubtitle::execute(project, req.path, req.offset, req.language); } ui.add_space(8.0); // ── Faixas externas adicionadas ─────────────────────── let remove_events = self.added_track_list.ui(ui, &project.tracks); for event in remove_events { use crate::ui::components::added_track_list::AddedTrackEvent; match event { AddedTrackEvent::RemoveRequested(id) => { let _ = RemoveTrack::execute(project, id); } } } ui.add_space(8.0); } }); }); } }