diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 78eb952..7fdce39 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -325,12 +325,12 @@ struct BatchItem { ### Tarefas -- [ ] Criar `enum ActiveTab` e barra de abas no `update()` de `App` -- [ ] Criar `BatchItem` e `batch_items: Vec` em `App` -- [ ] Criar componente `BatchPanel` com carrinho e formulário inline (Opção A) -- [ ] Implementar loop sequencial de execução em `App::process_batch()` -- [ ] Exibir estado individual por item (`Aguardando | Processando | Concluído | Erro`) -- [ ] Bloquear adição/remoção de itens durante processamento +- [x] Criar `enum ActiveTab` e barra de abas no `update()` de `App` +- [x] Criar `BatchItem` e `batch_items: Vec` em `App` +- [x] Criar componente `BatchPanel` com carrinho e formulário inline (Opção A) +- [x] Implementar loop sequencial de execução em `App::start_batch_item()` + avanço automático em `poll_background()` +- [x] Exibir estado individual por item (`Aguardando | Processando | Concluído | Erro`) +- [x] Bloquear adição/remoção de itens durante processamento --- diff --git a/PROGRESS.md b/PROGRESS.md index 4eb4d7c..7015360 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # Progresso de Implementação **Data:** 28/02/2026 -**Status:** Fases 1–7 concluídas — compilando, 33 testes passando, zero warnings do projeto, aplicação executável +**Status:** Fases 1–8 concluídas — compilando, 33 testes passando, zero warnings do projeto, aplicação executável **Referência:** DEVELOPMENT_PLAN.md v1.3 --- @@ -20,6 +20,7 @@ Após a conclusão das fases, foram implementadas funcionalidades adicionais: - Layout responsivo com painéis fixos (cabeçalho, rodapé, barra lateral) e área central com scroll - Progresso em tempo real via `run_ffmpeg_async` (Opção A: `std::sync::mpsc` em toda a cadeia) - Cancelamento de geração em andamento via botão "Cancelar" — encerra o processo FFmpeg filho imediatamente +- **Fase 8 — Modo Lote via Abas** — barra de abas ("Projeto Único" / "🗂 Lote"); `BatchPanel` com carrinho de projetos e formulário inline de adição; processamento sequencial automático via `start_batch_item` + avanço em `poll_background`; estado individual por item (`⏳ Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`); cancelamento do item atual com botão "⏹ Cancelar item atual"; carrinho bloqueado durante processamento --- @@ -34,6 +35,7 @@ Após a conclusão das fases, foram implementadas funcionalidades adicionais: | 5 | Infrastructure (processo async) | ✅ Concluída | | 6 | UI (eframe/egui) | ✅ Concluída | | 7 | Persistência do estado | ✅ Concluída | +| 8 | Modo Lote via Abas | ✅ Concluída | --- @@ -104,7 +106,8 @@ src/ ├── add_subtitle_form.rs — formulário: legenda externa ├── sync_offset_field.rs — campo de atraso em segundos ├── language_field.rs — campo de idioma ISO 639-2 - └── execution_panel.rs — botão gerar, botão cancelar, progresso, estados: Idle/Running/Success/Cancelled/Error + ├── execution_panel.rs — botão gerar, botão cancelar, progresso, estados: Idle/Running/Success/Cancelled/Error + └── batch_panel.rs — BatchItem, BatchPanel; carrinho de lote + formulário inline; estado por item ``` --- @@ -209,7 +212,7 @@ rfd = "0.14" - [x] Progresso em tempo real via `run_ffmpeg_async` — `run_ffmpeg_async` usa `std::sync::mpsc::Sender`; `start_generation` cria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término - [x] Cancelamento de geração — botão "Cancelar" visível durante `Running`; sinal via `tokio::sync::oneshot`; `child.kill().await` no `tokio::select!`; estado `ExecutionState::Cancelled` exibido em amarelo - [x] **Fase 7 — Persistência do Estado** — novo módulo `infrastructure/persistence` com `save_session`/`load_session`/`session_exists`; JSON em `~/.config/simple-mkv-editor/session.json`; botões "💾 Salvar sessão" / "📂 Carregar sessão" no header com feedback visual (verde/vermelho + botão ✕) -- [ ] **Fase 8 — Modo Lote via Abas** — navegação por abas (`Projeto Único` / `Lote`); `Vec` em `App`; processamento sequencial (um por vez); novo componente `BatchPanel` — ver DEVELOPMENT_PLAN.md Fase 8 +- [x] **Fase 8 — Modo Lote via Abas** — barra de abas "Projeto Único" / "🗂 Lote"; `BatchPanel` com carrinho + formulário inline; `start_batch_item` + avanço automático em `poll_background`; estado individual por item; cancelamento de item atual; carrinho bloqueado durante processamento ### Qualidade diff --git a/src/infrastructure/persistence/mod.rs b/src/infrastructure/persistence/mod.rs index d4f3181..eeabc5a 100644 --- a/src/infrastructure/persistence/mod.rs +++ b/src/infrastructure/persistence/mod.rs @@ -1,7 +1,17 @@ use crate::domain::entities::Project; use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Dados de sessão persistidos — abrange o Projeto Único e o carrinho de Lote. +#[derive(Serialize, Deserialize, Default)] +pub struct SessionData { + /// Projeto aberto na aba "Projeto Único". + pub single_project: Option, + /// Projetos do carrinho de Lote (estados de execução são descartados ao salvar). + pub batch_projects: Vec, +} + /// Retorna o caminho do arquivo de sessão de acordo com o SO. /// /// - Linux / macOS : `$HOME/.config/simple-mkv-editor/session.json` @@ -25,10 +35,10 @@ pub fn session_exists() -> bool { session_path().exists() } -/// Serializa o `Project` para JSON e grava no arquivo de sessão. +/// Serializa a `SessionData` para JSON e grava no arquivo de sessão. /// /// Cria o diretório pai automaticamente se ele não existir. -pub fn save_session(project: &Project) -> Result<()> { +pub fn save_session(data: &SessionData) -> Result<()> { let path = session_path(); if let Some(parent) = path.parent() { @@ -41,7 +51,7 @@ pub fn save_session(project: &Project) -> Result<()> { } let json = - serde_json::to_string_pretty(project).context("Falha ao serializar o estado do projeto")?; + serde_json::to_string_pretty(data).context("Falha ao serializar a sessão")?; std::fs::write(&path, json) .with_context(|| format!("Falha ao gravar sessão em: {}", path.display()))?; @@ -49,11 +59,11 @@ pub fn save_session(project: &Project) -> Result<()> { Ok(()) } -/// Lê e desserializa o `Project` do arquivo de sessão. +/// Lê e desserializa a `SessionData` do arquivo de sessão. /// /// Retorna erro amigável se o arquivo estiver ausente, corrompido ou em /// versão incompatível com a estrutura atual do domínio. -pub fn load_session() -> Result { +pub fn load_session() -> Result { let path = session_path(); let json = std::fs::read_to_string(&path).with_context(|| { @@ -63,8 +73,20 @@ pub fn load_session() -> Result { ) })?; - serde_json::from_str::(&json).with_context(|| { - "Falha ao carregar a sessão. O arquivo pode estar corrompido ou em versão incompatível. \ - Delete o arquivo de sessão e tente novamente." - }) + // Tenta desserializar como SessionData (formato novo). + // Se falhar, tenta o formato antigo (Project direto) e migra automaticamente. + if let Ok(data) = serde_json::from_str::(&json) { + return Ok(data); + } + + // Migração transparente: arquivo gerado pela Fase 7 continha apenas um Project. + serde_json::from_str::(&json) + .map(|project| SessionData { + single_project: Some(project), + batch_projects: Vec::new(), + }) + .with_context(|| { + "Falha ao carregar a sessão. O arquivo pode estar corrompido ou em versão incompatível. \ + Delete o arquivo de sessão e tente novamente." + }) } diff --git a/src/ui/app.rs b/src/ui/app.rs index d80db1f..6e1aa15 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -12,6 +12,7 @@ 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, @@ -20,6 +21,13 @@ use crate::ui::components::{ 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), @@ -53,6 +61,15 @@ pub struct App { // 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 { @@ -88,6 +105,10 @@ impl App { cancel_tx: None, global_error, session_msg, + active_tab: ActiveTab::Single, + batch_items: Vec::new(), + batch_panel: BatchPanel::new(), + batch_processing_index: None, } } @@ -123,34 +144,79 @@ impl App { } /// Processa mensagens pendentes do background thread. - fn poll_background(&mut self) { - let mut done = false; + /// 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)) => { - self.execution_panel.add_log(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) => { - self.execution_panel.state = ExecutionState::Success; - done = true; + done_result = Some(DoneResult::Ok); break; } Ok(BackgroundMsg::Error(e)) => { - self.execution_panel.state = ExecutionState::Error(e); - done = true; + done_result = Some(DoneResult::Err(e)); break; } Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { - done = true; + done_result = Some(DoneResult::Ok); break; } } } } - if done { + + 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); + } + } + } } } @@ -203,7 +269,7 @@ impl App { }); } - /// Cancela a geração em andamento, encerrando o processo FFmpeg. + /// 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(()); @@ -211,20 +277,80 @@ impl App { 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(); + 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 }; + 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| { @@ -236,31 +362,37 @@ impl eframe::App for App { egui::Layout::right_to_left(egui::Align::Center), |ui| { // Botão "Salvar sessão" - let can_save = self.project.is_some(); + 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 para poder salvar a sessão.", + "Abra um projeto ou adicione itens ao lote para salvar a sessão.", ) .clicked() { - if let Some(ref project) = self.project { - match crate::infrastructure::persistence::save_session( - project, - ) { - 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)); - } + 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)); } } } @@ -277,16 +409,34 @@ impl eframe::App for App { .clicked() { match crate::infrastructure::persistence::load_session() { - Ok(project) => { - 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); + Ok(data) => { + // Restaura projeto único + if let Some(project) = data.single_project { + 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) + if !data.batch_projects.is_empty() { + use crate::ui::components::batch_panel::BatchItem; + self.batch_items = data + .batch_projects + .into_iter() + .map(BatchItem::new) + .collect(); + } + let single_ok = self.project.is_some(); + let batch_count = self.batch_items.len(); self.session_msg = Some(( - "Sessão carregada com sucesso.".to_string(), + 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, )); } @@ -324,78 +474,132 @@ impl eframe::App for App { } }); - // ── Painel de execução (fixo no rodapé) ─────────────────────────────── - 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 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"); }); + }); - // ── Barra lateral esquerda: seleção de arquivos ─────────────────────── - egui::SidePanel::left("sidebar_panel") - .min_width(220.0) - .max_width(400.0) - .resizable(true) - .show(ctx, |ui| { + // ── 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| { - 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(); + 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(project) => { + self.batch_items.push(BatchItem::new(project)); + } + BatchPanelEvent::EditItem(idx, project) => { + 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(); + } } } - - 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); }); - }); + return; + } - // ── Painel central: faixas e formulários ───────────────────────────── - egui::CentralPanel::default().show(ctx, |ui| { + // Modo Projeto Único egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { @@ -422,11 +626,15 @@ impl eframe::App for App { .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::Audio => FilePickerAdapter::save_audio( + base_stem, + &track.codec, + ), TrackKind::Subtitle => { - FilePickerAdapter::save_subtitle(base_stem, &track.codec) + FilePickerAdapter::save_subtitle( + base_stem, + &track.codec, + ) } _ => None, }; diff --git a/src/ui/components/batch_panel.rs b/src/ui/components/batch_panel.rs new file mode 100644 index 0000000..65655ca --- /dev/null +++ b/src/ui/components/batch_panel.rs @@ -0,0 +1,522 @@ +use eframe::egui; + +use crate::adapters::filesystem::FilePickerAdapter; +use crate::application::use_cases::{add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle}; +use crate::domain::entities::{MkvOutput, Project, Track, VideoFile}; +use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; +use crate::ui::components::{ + add_audio_track_form::AddAudioTrackForm, add_subtitle_form::AddSubtitleForm, + execution_panel::ExecutionState, +}; + +// ── Tipos públicos ───────────────────────────────────────────────────────────── + +/// Um item no carrinho de lote — projeto completo com estado individual de execução. +pub struct BatchItem { + pub project: Project, + pub state: ExecutionState, + pub log_lines: Vec, +} + +impl BatchItem { + pub fn new(project: Project) -> Self { + BatchItem { + project, + state: ExecutionState::Idle, + log_lines: Vec::new(), + } + } +} + +/// Eventos emitidos pelo `BatchPanel` para o `App`. +pub enum BatchPanelEvent { + /// Novo projeto confirmado pelo usuário — adicionar ao carrinho. + AddItem(Project), + /// Substituir o projeto de um item existente (edição confirmada). + EditItem(usize, Project), + /// Remover item do carinho pelo índice. + RemoveItem(usize), + /// Iniciar o processamento sequencial de todos os itens aguardando. + ProcessAll, + /// Cancelar o item que está atualmente em execução. + CancelCurrent, +} + +// ── Tipos internos do formulário ────────────────────────────────────────────── + +struct PendingAudio { + path: FilePath, + offset: SyncOffset, + language: TrackLanguage, +} + +struct PendingSubtitle { + path: FilePath, + offset: SyncOffset, + language: TrackLanguage, +} + +// ── BatchPanel ───────────────────────────────────────────────────────────────── + +/// Painel de modo lote: exibe o carrinho de projetos e um formulário inline +/// para adicionar ou editar itens sem abandonar a aba. +pub struct BatchPanel { + // Estado do formulário de adição / edição + form_open: bool, + /// Índice do item sendo editado; `None` quando o formulário é para adição. + editing_index: Option, + form_video: Option, + form_output: Option, + form_error: Option, + form_pending_audio: Vec, + form_pending_subtitles: Vec, + form_add_audio: AddAudioTrackForm, + form_add_subtitle: AddSubtitleForm, +} + +impl BatchPanel { + pub fn new() -> Self { + BatchPanel { + form_open: false, + editing_index: None, + form_video: None, + form_output: None, + form_error: None, + form_pending_audio: Vec::new(), + form_pending_subtitles: Vec::new(), + form_add_audio: AddAudioTrackForm::new(), + form_add_subtitle: AddSubtitleForm::new(), + } + } + + /// Limpa todos os campos do formulário e fecha-o. + fn reset_form(&mut self) { + self.form_open = false; + self.editing_index = None; + self.form_video = None; + self.form_output = None; + self.form_error = None; + self.form_pending_audio.clear(); + self.form_pending_subtitles.clear(); + self.form_add_audio = AddAudioTrackForm::new(); + self.form_add_subtitle = AddSubtitleForm::new(); + } + + /// Abre o formulário pré-preenchido com os dados de um item existente. + fn open_edit_form(&mut self, index: usize, item: &BatchItem) { + self.reset_form(); + self.form_open = true; + self.editing_index = Some(index); + self.form_video = Some(item.project.source.path.clone()); + self.form_output = Some(item.project.output.path.clone()); + + for track in &item.project.tracks { + match track { + Track::Audio(t) => self.form_pending_audio.push(PendingAudio { + path: t.path.clone(), + offset: t.offset, + language: t.language.clone(), + }), + Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle { + path: t.path.clone(), + offset: t.offset, + language: t.language.clone(), + }), + } + } + } + + /// Renderiza o painel completo. Retorna a lista de eventos gerados pelo usuário. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + items: &[BatchItem], + is_processing: bool, + ) -> Vec { + let mut events = Vec::new(); + + ui.heading("Modo Lote"); + ui.label( + egui::RichText::new( + "Adicione múltiplos projetos ao carrinho e processe-os sequencialmente.", + ) + .weak() + .italics(), + ); + ui.add_space(8.0); + + // ── Lista de itens do carrinho ───────────────────────────────────────── + if items.is_empty() && !self.form_open { + ui.label( + egui::RichText::new( + "Nenhum item no carrinho. Clique em \"+ Adicionar item\" para começar.", + ) + .weak() + .italics(), + ); + } else { + // Coleta pedidos de edição para abrir depois (evita borrow conflict com self) + let mut open_edit_for: Option = None; + + for (idx, item) in items.iter().enumerate() { + // Não renderiza o item sendo editado — ele aparece no formulário abaixo + if self.editing_index == Some(idx) { + continue; + } + + let source_name = item + .project + .source + .path + .as_path() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("—"); + let output_name = item + .project + .output + .path + .as_path() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("—"); + + ui.group(|ui| { + ui.horizontal(|ui| { + let (icon, color) = match &item.state { + ExecutionState::Idle => ("⏳", egui::Color32::GRAY), + ExecutionState::Running => ("⟳", egui::Color32::YELLOW), + ExecutionState::Success => ("✓", egui::Color32::GREEN), + ExecutionState::Cancelled => { + ("⊘", egui::Color32::from_rgb(255, 200, 0)) + } + ExecutionState::Error(_) => ("✗", egui::Color32::RED), + }; + ui.colored_label(color, icon); + ui.label(format!("#{} — {} → {}", idx + 1, source_name, output_name)); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if !is_processing && matches!(item.state, ExecutionState::Idle) { + if ui.small_button("✕ Remover").clicked() { + events.push(BatchPanelEvent::RemoveItem(idx)); + } + if ui.small_button("✏ Editar").clicked() { + open_edit_for = Some(idx); + } + } + }); + }); + + let track_count = item.project.tracks.len(); + let existing_count = item.project.existing_tracks.len(); + ui.label( + egui::RichText::new(format!( + " {} faixas existentes · {} faixas adicionadas", + existing_count, track_count + )) + .small() + .weak(), + ); + + if let ExecutionState::Error(msg) = &item.state { + ui.colored_label(egui::Color32::RED, format!(" Erro: {}", msg)); + } + + if !item.log_lines.is_empty() { + ui.collapsing("Log detalhado", |ui| { + egui::ScrollArea::vertical() + .max_height(120.0) + .show(ui, |ui| { + for line in &item.log_lines { + ui.label(egui::RichText::new(line).monospace().size(10.0)); + } + }); + }); + } + }); + ui.add_space(4.0); + } + + // Abre edição após o loop para evitar borrow imutável simultâneo + if let Some(idx) = open_edit_for { + self.open_edit_form(idx, &items[idx]); + } + } + + // ── Botão "Adicionar item" / formulário ────────────────────────────── + ui.add_space(4.0); + if !is_processing { + if !self.form_open { + if ui.button("+ Adicionar item").clicked() { + self.reset_form(); + self.form_open = true; + } + } else { + self.render_form(ui, &mut events); + } + } + + // ── Botões de controle do lote ──────────────────────────────────────── + if !items.is_empty() { + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + ui.horizontal(|ui| { + let has_waiting = items + .iter() + .any(|item| matches!(item.state, ExecutionState::Idle)); + let all_finished = items.iter().all(|item| { + matches!( + item.state, + ExecutionState::Success + | ExecutionState::Error(_) + | ExecutionState::Cancelled + ) + }); + + if is_processing { + if ui + .add( + egui::Button::new("⏹ Cancelar item atual") + .fill(egui::Color32::DARK_RED), + ) + .on_hover_text( + "Interrompe o item em execução; os demais permanecem no carrinho.", + ) + .clicked() + { + events.push(BatchPanelEvent::CancelCurrent); + } + } else if has_waiting { + if ui.button("▶ Processar Tudo").clicked() { + events.push(BatchPanelEvent::ProcessAll); + } + } else if all_finished && !items.is_empty() { + ui.colored_label(egui::Color32::GREEN, "✓ Lote concluído."); + } + }); + } + + events + } + + /// Renderiza o formulário inline de adição ou edição de item. + fn render_form(&mut self, ui: &mut egui::Ui, events: &mut Vec) { + let is_edit = self.editing_index.is_some(); + let title = if let Some(idx) = self.editing_index { + format!("Editar item #{}", idx + 1) + } else { + "Novo item".to_string() + }; + + ui.group(|ui| { + ui.heading(&title); + + // ── Seleção de vídeo ────────────────────────────────────────────── + ui.horizontal(|ui| { + ui.label("Vídeo:"); + let label = self + .form_video + .as_ref() + .and_then(|p| p.as_path().file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("Nenhum selecionado"); + ui.add(egui::Label::new(label).truncate(true)); + if ui.button("Escolher…").clicked() { + self.form_video = FilePickerAdapter::pick_video(); + self.form_error = None; + } + }); + + // ── Seleção de saída ────────────────────────────────────────────── + ui.horizontal(|ui| { + ui.label("Saída:"); + let label = self + .form_output + .as_ref() + .and_then(|p| p.as_path().file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("Nenhum selecionado"); + ui.add(egui::Label::new(label).truncate(true)); + if ui.button("Escolher…").clicked() { + let stem = self + .form_video + .as_ref() + .and_then(|p| p.as_path().file_stem()) + .and_then(|s| s.to_str()) + .unwrap_or("output"); + self.form_output = FilePickerAdapter::pick_output(stem); + self.form_error = None; + } + }); + + ui.separator(); + + // ── Faixas já configuradas (com remoção individual) ─────────────── + if !self.form_pending_audio.is_empty() || !self.form_pending_subtitles.is_empty() { + ui.label(egui::RichText::new("Faixas configuradas:").small()); + + let mut remove_audio: Option = None; + for (i, audio) in self.form_pending_audio.iter().enumerate() { + let name = audio + .path + .as_path() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("—"); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!(" 🎵 {} [{}]", name, audio.language)) + .small(), + ); + if ui + .small_button("✕") + .on_hover_text("Remover faixa") + .clicked() + { + remove_audio = Some(i); + } + }); + } + if let Some(i) = remove_audio { + self.form_pending_audio.remove(i); + } + + let mut remove_sub: Option = None; + for (i, sub) in self.form_pending_subtitles.iter().enumerate() { + let name = sub + .path + .as_path() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("—"); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!(" 💬 {} [{}]", name, sub.language)) + .small(), + ); + if ui + .small_button("✕") + .on_hover_text("Remover faixa") + .clicked() + { + remove_sub = Some(i); + } + }); + } + if let Some(i) = remove_sub { + self.form_pending_subtitles.remove(i); + } + + ui.add_space(4.0); + } + + // ── Formulários de faixas (compactos) ───────────────────────────── + let wide = ui.available_width() >= 500.0; + let (audio_req, subtitle_req) = if wide { + let available = ui.available_width(); + let col_w = (available - ui.spacing().item_spacing.x) / 2.0; + + let ar = ui + .allocate_ui_with_layout( + egui::Vec2::new(col_w, 0.0), + egui::Layout::top_down(egui::Align::Min), + |ui| self.form_add_audio.ui(ui), + ) + .inner; + + let sr = ui + .allocate_ui_with_layout( + egui::Vec2::new(col_w, 0.0), + egui::Layout::top_down(egui::Align::Min), + |ui| self.form_add_subtitle.ui(ui), + ) + .inner; + + (ar, sr) + } else { + let ar = self.form_add_audio.ui(ui); + ui.add_space(2.0); + let sr = self.form_add_subtitle.ui(ui); + (ar, sr) + }; + + if let Some(req) = audio_req { + self.form_pending_audio.push(PendingAudio { + path: req.path, + offset: req.offset, + language: req.language, + }); + } + if let Some(req) = subtitle_req { + self.form_pending_subtitles.push(PendingSubtitle { + path: req.path, + offset: req.offset, + language: req.language, + }); + } + + // ── Mensagem de erro ────────────────────────────────────────────── + if let Some(ref err) = self.form_error { + ui.colored_label(egui::Color32::RED, err); + } + + ui.add_space(4.0); + + // ── Botões de confirmação / cancelar ────────────────────────────── + ui.horizontal(|ui| { + if ui.button("Cancelar").clicked() { + self.reset_form(); + } + + let can_add = self.form_video.is_some() && self.form_output.is_some(); + let confirm_label = if is_edit { + "Salvar alterações ✓" + } else { + "Adicionar ✓" + }; + + if ui + .add_enabled(can_add, egui::Button::new(confirm_label)) + .on_disabled_hover_text("Selecione o vídeo e o destino de saída.") + .clicked() + { + let source_path = self.form_video.clone().unwrap(); + let output_path = self.form_output.clone().unwrap(); + + match Project::new(VideoFile::new(source_path), MkvOutput::new(output_path)) { + Ok(mut project) => { + let audio = std::mem::take(&mut self.form_pending_audio); + let subs = std::mem::take(&mut self.form_pending_subtitles); + for t in audio { + let _ = AddAudioTrack::execute( + &mut project, + t.path, + t.offset, + t.language, + ); + } + for s in subs { + let _ = AddSubtitle::execute( + &mut project, + s.path, + s.offset, + s.language, + ); + } + if let Some(idx) = self.editing_index { + events.push(BatchPanelEvent::EditItem(idx, project)); + } else { + events.push(BatchPanelEvent::AddItem(project)); + } + self.reset_form(); + } + Err(e) => { + self.form_error = Some(e.to_string()); + } + } + } + }); + }); + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 7bafafe..142293e 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,6 +1,7 @@ pub mod add_audio_track_form; pub mod add_subtitle_form; pub mod added_track_list; +pub mod batch_panel; pub mod execution_panel; pub mod existing_track_list; pub mod language_field;