Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e38c51e3a | ||
|
|
d69ce92e32 | ||
|
|
121e919bbf |
+6
-6
@@ -325,12 +325,12 @@ struct BatchItem {
|
|||||||
|
|
||||||
### Tarefas
|
### Tarefas
|
||||||
|
|
||||||
- [ ] Criar `enum ActiveTab` e barra de abas no `update()` de `App`
|
- [x] Criar `enum ActiveTab` e barra de abas no `update()` de `App`
|
||||||
- [ ] Criar `BatchItem` e `batch_items: Vec<BatchItem>` em `App`
|
- [x] Criar `BatchItem` e `batch_items: Vec<BatchItem>` em `App`
|
||||||
- [ ] Criar componente `BatchPanel` com carrinho e formulário inline (Opção A)
|
- [x] Criar componente `BatchPanel` com carrinho e formulário inline (Opção A)
|
||||||
- [ ] Implementar loop sequencial de execução em `App::process_batch()`
|
- [x] Implementar loop sequencial de execução em `App::start_batch_item()` + avanço automático em `poll_background()`
|
||||||
- [ ] Exibir estado individual por item (`Aguardando | Processando | Concluído | Erro`)
|
- [x] Exibir estado individual por item (`Aguardando | Processando | Concluído | Erro`)
|
||||||
- [ ] Bloquear adição/remoção de itens durante processamento
|
- [x] Bloquear adição/remoção de itens durante processamento
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -1,7 +1,7 @@
|
|||||||
# Progresso de Implementação
|
# Progresso de Implementação
|
||||||
|
|
||||||
**Data:** 28/02/2026
|
**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
|
**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
|
- 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)
|
- 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
|
- 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 |
|
| 5 | Infrastructure (processo async) | ✅ Concluída |
|
||||||
| 6 | UI (eframe/egui) | ✅ Concluída |
|
| 6 | UI (eframe/egui) | ✅ Concluída |
|
||||||
| 7 | Persistência do estado | ✅ 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
|
├── add_subtitle_form.rs — formulário: legenda externa
|
||||||
├── sync_offset_field.rs — campo de atraso em segundos
|
├── sync_offset_field.rs — campo de atraso em segundos
|
||||||
├── language_field.rs — campo de idioma ISO 639-2
|
├── 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<String>`; `start_generation` cria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término
|
- [x] Progresso em tempo real via `run_ffmpeg_async` — `run_ffmpeg_async` usa `std::sync::mpsc::Sender<String>`; `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] 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 ✕)
|
- [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<BatchItem>` 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
|
### Qualidade
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ impl FfmpegCommandBuilder {
|
|||||||
pub fn build(project: &Project) -> Vec<String> {
|
pub fn build(project: &Project) -> Vec<String> {
|
||||||
let mut args: Vec<String> = Vec::new();
|
let mut args: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Sobrescreve o arquivo de saída sem prompt interativo.
|
||||||
|
// Sem isso o FFmpeg aguarda [y/N] no stdin e o processo trava.
|
||||||
|
args.push("-y".to_string());
|
||||||
|
|
||||||
// ── Inputs ──────────────────────────────────────────────────────────────
|
// ── Inputs ──────────────────────────────────────────────────────────────
|
||||||
// Input 0: arquivo fonte (sempre sem itsoffset próprio)
|
// Input 0: arquivo fonte (sempre sem itsoffset próprio)
|
||||||
args.push("-i".to_string());
|
args.push("-i".to_string());
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
use crate::domain::entities::Project;
|
use crate::domain::entities::Project;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
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<Project>,
|
||||||
|
/// Projetos do carrinho de Lote (estados de execução são descartados ao salvar).
|
||||||
|
pub batch_projects: Vec<Project>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Retorna o caminho do arquivo de sessão de acordo com o SO.
|
/// Retorna o caminho do arquivo de sessão de acordo com o SO.
|
||||||
///
|
///
|
||||||
/// - Linux / macOS : `$HOME/.config/simple-mkv-editor/session.json`
|
/// - Linux / macOS : `$HOME/.config/simple-mkv-editor/session.json`
|
||||||
@@ -25,10 +35,10 @@ pub fn session_exists() -> bool {
|
|||||||
session_path().exists()
|
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.
|
/// 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();
|
let path = session_path();
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -41,7 +51,7 @@ pub fn save_session(project: &Project) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let json =
|
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)
|
std::fs::write(&path, json)
|
||||||
.with_context(|| format!("Falha ao gravar sessão em: {}", path.display()))?;
|
.with_context(|| format!("Falha ao gravar sessão em: {}", path.display()))?;
|
||||||
@@ -49,11 +59,11 @@ pub fn save_session(project: &Project) -> Result<()> {
|
|||||||
Ok(())
|
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
|
/// Retorna erro amigável se o arquivo estiver ausente, corrompido ou em
|
||||||
/// versão incompatível com a estrutura atual do domínio.
|
/// versão incompatível com a estrutura atual do domínio.
|
||||||
pub fn load_session() -> Result<Project> {
|
pub fn load_session() -> Result<SessionData> {
|
||||||
let path = session_path();
|
let path = session_path();
|
||||||
|
|
||||||
let json = std::fs::read_to_string(&path).with_context(|| {
|
let json = std::fs::read_to_string(&path).with_context(|| {
|
||||||
@@ -63,7 +73,19 @@ pub fn load_session() -> Result<Project> {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
serde_json::from_str::<Project>(&json).with_context(|| {
|
// 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::<SessionData>(&json) {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migração transparente: arquivo gerado pela Fase 7 continha apenas um Project.
|
||||||
|
serde_json::from_str::<crate::domain::entities::Project>(&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. \
|
"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."
|
Delete o arquivo de sessão e tente novamente."
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub async fn run_ffmpeg_async(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut child = Command::new("ffmpeg")
|
let mut child = Command::new("ffmpeg")
|
||||||
.args(&args)
|
.args(&args)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|||||||
+238
-30
@@ -12,6 +12,7 @@ use crate::ui::components::{
|
|||||||
add_audio_track_form::AddAudioTrackForm,
|
add_audio_track_form::AddAudioTrackForm,
|
||||||
add_subtitle_form::AddSubtitleForm,
|
add_subtitle_form::AddSubtitleForm,
|
||||||
added_track_list::AddedTrackList,
|
added_track_list::AddedTrackList,
|
||||||
|
batch_panel::{BatchItem, BatchPanel, BatchPanelEvent},
|
||||||
execution_panel::{ExecutionPanel, ExecutionState},
|
execution_panel::{ExecutionPanel, ExecutionState},
|
||||||
existing_track_list::ExistingTrackList,
|
existing_track_list::ExistingTrackList,
|
||||||
output_selector::OutputSelector,
|
output_selector::OutputSelector,
|
||||||
@@ -20,6 +21,13 @@ use crate::ui::components::{
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
/// Aba ativa da interface.
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum ActiveTab {
|
||||||
|
Single,
|
||||||
|
Batch,
|
||||||
|
}
|
||||||
|
|
||||||
/// Mensagens enviadas do background thread para a UI.
|
/// Mensagens enviadas do background thread para a UI.
|
||||||
enum BackgroundMsg {
|
enum BackgroundMsg {
|
||||||
LogLine(String),
|
LogLine(String),
|
||||||
@@ -53,6 +61,15 @@ pub struct App {
|
|||||||
|
|
||||||
// Mensagem de feedback de persistência (sucesso ou erro)
|
// Mensagem de feedback de persistência (sucesso ou erro)
|
||||||
session_msg: Option<(String, bool)>, // (texto, é_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<BatchItem>,
|
||||||
|
// Painel de modo lote com estado próprio
|
||||||
|
batch_panel: BatchPanel,
|
||||||
|
// Índice do item sendo processado no lote (None = nenhum)
|
||||||
|
batch_processing_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -88,6 +105,10 @@ impl App {
|
|||||||
cancel_tx: None,
|
cancel_tx: None,
|
||||||
global_error,
|
global_error,
|
||||||
session_msg,
|
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.
|
/// Processa mensagens pendentes do background thread.
|
||||||
fn poll_background(&mut self) {
|
/// Em modo lote, atualiza o item em execução e avança para o próximo automaticamente.
|
||||||
let mut done = false;
|
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<DoneResult> = None;
|
||||||
|
|
||||||
if let Some(rx) = &self.bg_rx {
|
if let Some(rx) = &self.bg_rx {
|
||||||
loop {
|
loop {
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(BackgroundMsg::LogLine(line)) => {
|
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);
|
self.execution_panel.add_log(line);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(BackgroundMsg::Done) => {
|
Ok(BackgroundMsg::Done) => {
|
||||||
self.execution_panel.state = ExecutionState::Success;
|
done_result = Some(DoneResult::Ok);
|
||||||
done = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(BackgroundMsg::Error(e)) => {
|
Ok(BackgroundMsg::Error(e)) => {
|
||||||
self.execution_panel.state = ExecutionState::Error(e);
|
done_result = Some(DoneResult::Err(e));
|
||||||
done = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(mpsc::TryRecvError::Empty) => break,
|
Err(mpsc::TryRecvError::Empty) => break,
|
||||||
Err(mpsc::TryRecvError::Disconnected) => {
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
done = true;
|
done_result = Some(DoneResult::Ok);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if done {
|
|
||||||
|
if let Some(result) = done_result {
|
||||||
self.bg_rx = None;
|
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) {
|
fn cancel_generation(&mut self) {
|
||||||
if let Some(tx) = self.cancel_tx.take() {
|
if let Some(tx) = self.cancel_tx.take() {
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
@@ -211,20 +277,80 @@ impl App {
|
|||||||
self.bg_rx = None;
|
self.bg_rx = None;
|
||||||
self.execution_panel.state = ExecutionState::Cancelled;
|
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::<BackgroundMsg>();
|
||||||
|
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::<String>();
|
||||||
|
|
||||||
|
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 {
|
impl eframe::App for App {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
// Consome mensagens assíncronas
|
// Consome mensagens assíncronas
|
||||||
self.poll_background();
|
self.poll_background(ctx);
|
||||||
if self.bg_rx.is_some() {
|
if self.bg_rx.is_some() {
|
||||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cabeçalho (fixo no topo) ──────────────────────────────────────────
|
// ── Cabeçalho (fixo no topo) ──────────────────────────────────────────
|
||||||
let header_height = 42.0
|
let header_height =
|
||||||
+ if self.global_error.is_some() { 22.0 } else { 0.0 }
|
42.0 + if self.global_error.is_some() {
|
||||||
+ if self.session_msg.is_some() { 22.0 } else { 0.0 };
|
22.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
} + if self.session_msg.is_some() {
|
||||||
|
22.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
egui::TopBottomPanel::top("header_panel")
|
egui::TopBottomPanel::top("header_panel")
|
||||||
.exact_height(header_height)
|
.exact_height(header_height)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
@@ -236,21 +362,28 @@ impl eframe::App for App {
|
|||||||
egui::Layout::right_to_left(egui::Align::Center),
|
egui::Layout::right_to_left(egui::Align::Center),
|
||||||
|ui| {
|
|ui| {
|
||||||
// Botão "Salvar sessão"
|
// 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
|
if ui
|
||||||
.add_enabled(
|
.add_enabled(
|
||||||
can_save,
|
can_save,
|
||||||
egui::Button::new("💾 Salvar sessão"),
|
egui::Button::new("💾 Salvar sessão"),
|
||||||
)
|
)
|
||||||
.on_disabled_hover_text(
|
.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()
|
.clicked()
|
||||||
{
|
{
|
||||||
if let Some(ref project) = self.project {
|
use crate::infrastructure::persistence::SessionData;
|
||||||
match crate::infrastructure::persistence::save_session(
|
let data = SessionData {
|
||||||
project,
|
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(_) => {
|
Ok(_) => {
|
||||||
self.session_msg = Some((
|
self.session_msg = Some((
|
||||||
"Sessão salva com sucesso.".to_string(),
|
"Sessão salva com sucesso.".to_string(),
|
||||||
@@ -263,7 +396,6 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Botão "Carregar sessão"
|
// Botão "Carregar sessão"
|
||||||
let can_load =
|
let can_load =
|
||||||
@@ -277,7 +409,9 @@ impl eframe::App for App {
|
|||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
match crate::infrastructure::persistence::load_session() {
|
match crate::infrastructure::persistence::load_session() {
|
||||||
Ok(project) => {
|
Ok(data) => {
|
||||||
|
// Restaura projeto único
|
||||||
|
if let Some(project) = data.single_project {
|
||||||
self.pending_source =
|
self.pending_source =
|
||||||
Some(project.source.path.clone());
|
Some(project.source.path.clone());
|
||||||
self.pending_output =
|
self.pending_output =
|
||||||
@@ -285,8 +419,24 @@ impl eframe::App for App {
|
|||||||
self.existing_track_list
|
self.existing_track_list
|
||||||
.sync_tracks(&project.existing_tracks);
|
.sync_tracks(&project.existing_tracks);
|
||||||
self.project = Some(project);
|
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((
|
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,
|
false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -324,12 +474,22 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Painel de execução (fixo no rodapé) ───────────────────────────────
|
// ── 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")
|
egui::TopBottomPanel::bottom("execution_panel")
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
let can_generate = self.pending_source.is_some() && self.pending_output.is_some();
|
let can_generate =
|
||||||
|
self.pending_source.is_some() && self.pending_output.is_some();
|
||||||
let (generate, cancel) = self.execution_panel.ui(ui, can_generate);
|
let (generate, cancel) = self.execution_panel.ui(ui, can_generate);
|
||||||
if generate {
|
if generate {
|
||||||
let ctx_clone = ctx.clone();
|
let ctx_clone = ctx.clone();
|
||||||
@@ -340,8 +500,10 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Barra lateral esquerda: seleção de arquivos ───────────────────────
|
// ── Barra lateral esquerda (apenas modo Projeto Único) ─────────────────
|
||||||
|
if self.active_tab == ActiveTab::Single {
|
||||||
egui::SidePanel::left("sidebar_panel")
|
egui::SidePanel::left("sidebar_panel")
|
||||||
.min_width(220.0)
|
.min_width(220.0)
|
||||||
.max_width(400.0)
|
.max_width(400.0)
|
||||||
@@ -393,9 +555,51 @@ impl eframe::App for App {
|
|||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} // fim da barra lateral (modo Projeto Único)
|
||||||
|
|
||||||
// ── Painel central: faixas e formulários ─────────────────────────────
|
// ── Painel central: faixas / formulários ou painel de lote ───────────
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
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(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo Projeto Único
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
@@ -422,11 +626,15 @@ impl eframe::App for App {
|
|||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("track");
|
.unwrap_or("track");
|
||||||
let maybe_output = match track.kind {
|
let maybe_output = match track.kind {
|
||||||
TrackKind::Audio => {
|
TrackKind::Audio => FilePickerAdapter::save_audio(
|
||||||
FilePickerAdapter::save_audio(base_stem, &track.codec)
|
base_stem,
|
||||||
}
|
&track.codec,
|
||||||
|
),
|
||||||
TrackKind::Subtitle => {
|
TrackKind::Subtitle => {
|
||||||
FilePickerAdapter::save_subtitle(base_stem, &track.codec)
|
FilePickerAdapter::save_subtitle(
|
||||||
|
base_stem,
|
||||||
|
&track.codec,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize>,
|
||||||
|
form_video: Option<FilePath>,
|
||||||
|
form_output: Option<FilePath>,
|
||||||
|
form_error: Option<String>,
|
||||||
|
form_pending_audio: Vec<PendingAudio>,
|
||||||
|
form_pending_subtitles: Vec<PendingSubtitle>,
|
||||||
|
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<BatchPanelEvent> {
|
||||||
|
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<usize> = 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.push_id(("batch_item", idx), |ui| {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}); // push_id
|
||||||
|
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<BatchPanelEvent>) {
|
||||||
|
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<usize> = 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.push_id(("audio", i), |ui| {
|
||||||
|
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<usize> = 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.push_id(("sub", i), |ui| {
|
||||||
|
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 ao carrinho"
|
||||||
|
};
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,13 +76,13 @@ impl ExecutionPanel {
|
|||||||
ui.label("Processando...");
|
ui.label("Processando...");
|
||||||
}
|
}
|
||||||
ExecutionState::Success => {
|
ExecutionState::Success => {
|
||||||
ui.colored_label(egui::Color32::GREEN, "✓ Arquivo gerado com sucesso!");
|
ui.colored_label(egui::Color32::GREEN, "✔ Arquivo gerado com sucesso!");
|
||||||
}
|
}
|
||||||
ExecutionState::Cancelled => {
|
ExecutionState::Cancelled => {
|
||||||
ui.colored_label(egui::Color32::YELLOW, "⊘ Geração cancelada pelo usuário.");
|
ui.colored_label(egui::Color32::YELLOW, "⊘ Geração cancelada pelo usuário.");
|
||||||
}
|
}
|
||||||
ExecutionState::Error(msg) => {
|
ExecutionState::Error(msg) => {
|
||||||
ui.colored_label(egui::Color32::RED, "✗ Erro durante o processamento:");
|
ui.colored_label(egui::Color32::RED, "✘ Erro durante o processamento:");
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::multiline(&mut msg.as_str())
|
egui::TextEdit::multiline(&mut msg.as_str())
|
||||||
.desired_rows(4)
|
.desired_rows(4)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod add_audio_track_form;
|
pub mod add_audio_track_form;
|
||||||
pub mod add_subtitle_form;
|
pub mod add_subtitle_form;
|
||||||
pub mod added_track_list;
|
pub mod added_track_list;
|
||||||
|
pub mod batch_panel;
|
||||||
pub mod execution_panel;
|
pub mod execution_panel;
|
||||||
pub mod existing_track_list;
|
pub mod existing_track_list;
|
||||||
pub mod language_field;
|
pub mod language_field;
|
||||||
|
|||||||
Reference in New Issue
Block a user