Compare commits

..
2 Commits
8 changed files with 450 additions and 22 deletions
+16 -6
View File
@@ -1,9 +1,9 @@
# PRD — Simple Multimedia Track Audio Editor # PRD — Simple Multimedia Track Audio Editor
**Versão:** 1.4 **Versão:** 1.5
**Data:** 01/03/2026 **Data:** 02/03/2026
**Status:** v1.0 funcional — Fases 19 concluídas; 33 testes passando; aplicação executável **Status:** v1.0 funcional — Fases 19 concluídas; 72 testes passando; aplicação executável
**Revisão:** v1.4 — Adicionado RF-15 (Excluir faixa existente do output) **Revisão:** v1.5 — Adicionado RF-16 (Reordenar faixas externas)
--- ---
@@ -167,6 +167,15 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
- Ambos os pipelines (FFmpeg e mkvmerge) respeitam o flag: faixas excluídas são filtradas antes da geração dos argumentos de `-map` / `--audio-tracks` / `--subtitle-tracks` - Ambos os pipelines (FFmpeg e mkvmerge) respeitam o flag: faixas excluídas são filtradas antes da geração dos argumentos de `-map` / `--audio-tracks` / `--subtitle-tracks`
- O estado `excluded` é persistido junto com a sessão (RF-14) - O estado `excluded` é persistido junto com a sessão (RF-14)
### RF-16 — Reordenar faixas externas
- O usuário pode alterar a ordem das faixas externas adicionadas (áudio e legenda) usando os botões **↑** e **↓** na lista de faixas
- A ordem determina o índice de stream no container MKV final — relevante para players que selecionam faixas por posição (ex: VLC, Jellyfin)
- O botão ↑ fica desabilitado na primeira faixa da lista; o botão ↓, na última
- Implementado via método `move_track(id, delta: i8)` em `Project`, usando `Vec::swap` — ambos os pipelines (FFmpeg e mkvmerge) iteram `project.tracks` em ordem e não precisam de alteração
- Somente faixas **externas** (`project.tracks`) são reordenáveis; faixas existentes (`project.existing_tracks`, lidas via ffprobe) permanecem na ordem detectada
- A nova ordem é persistida automaticamente junto com a sessão (RF-14)
--- ---
## 7. Requisitos Não Funcionais ## 7. Requisitos Não Funcionais
@@ -393,7 +402,7 @@ src/
| DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando — RF-10 (`title`) e RF-11 (`disposition`) implementados; comportamento no Jellyfin mobile requer testes com arquivo gerado | | DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando — RF-10 (`title`) e RF-11 (`disposition`) implementados; comportamento no Jellyfin mobile requer testes com arquivo gerado |
| DT-08 | mkvmerge não embutido | Usuário precisa instalar MKVToolNix | Dependência opcional; feat. de drift desabilitada visualmente se ausente; verificação soft na inicialização | | DT-08 | mkvmerge não embutido | Usuário precisa instalar MKVToolNix | Dependência opcional; feat. de drift desabilitada visualmente se ausente; verificação soft na inicialização |
| DT-09 | Testes de integração com FFmpeg real | Regressões em comandos gerados | Pendente — requer `ffmpeg` no CI; coberto indiretamente pelos testes unitários do `FfmpegCommandBuilder` | | DT-09 | Testes de integração com FFmpeg real | Regressões em comandos gerados | Pendente — requer `ffmpeg` no CI; coberto indiretamente pelos testes unitários do `FfmpegCommandBuilder` |
| DT-10 | Comando FFmpeg não visível ao usuário | Difícil de debugar manualmente | Planejado — painel colapsável com o comando gerado na `ExecutionPanel` (modo debug) | | DT-10 | Comando FFmpeg não visível ao usuário | Difícil de debugar manualmente | ✅ Resolvido — painel colapsável "🛠 Comando gerado" na `ExecutionPanel` e em cada item do modo Lote; botão de cópia para clipboard |
--- ---
@@ -402,7 +411,6 @@ src/
- Preview de vídeo embutido na interface - Preview de vídeo embutido na interface
- Detecção automática de sincronização entre faixas - Detecção automática de sincronização entre faixas
- Suporte a perfis de configuração reutilizáveis (presets de idioma, offset, drift) - Suporte a perfis de configuração reutilizáveis (presets de idioma, offset, drift)
- Painel colapsável com o comando FFmpeg/mkvmerge gerado (modo debug, DT-10)
- Mensagens de erro mais amigáveis a partir do stderr do FFmpeg - Mensagens de erro mais amigáveis a partir do stderr do FFmpeg
- Testes de integração com FFmpeg real no CI (DT-09) - Testes de integração com FFmpeg real no CI (DT-09)
- Testes de snapshot para o `FfmpegCommandBuilder` e `MkvmergeCommandBuilder` com projetos complexos - Testes de snapshot para o `FfmpegCommandBuilder` e `MkvmergeCommandBuilder` com projetos complexos
@@ -423,6 +431,7 @@ src/
- [x] É possível exportar uma faixa existente para arquivo separado (RF-13) - [x] É possível exportar uma faixa existente para arquivo separado (RF-13)
- [x] É possível salvar e restaurar a sessão em disco (RF-14) - [x] É possível salvar e restaurar a sessão em disco (RF-14)
- [x] É possível excluir faixas de áudio ou legenda existentes do arquivo de saída, com possibilidade de restauração antes da geração (RF-15) - [x] É possível excluir faixas de áudio ou legenda existentes do arquivo de saída, com possibilidade de restauração antes da geração (RF-15)
- [x] É possível reordenar as faixas externas adicionadas com botões ↑↓; a ordem reflete o índice no container final (RF-16)
- [x] O modo lote permite configurar e processar múltiplos projetos sequencialmente (RF-09) - [x] O modo lote permite configurar e processar múltiplos projetos sequencialmente (RF-09)
- [x] É possível definir fator de correção de drift por faixa (RF-12; requer mkvmerge) - [x] É possível definir fator de correção de drift por faixa (RF-12; requer mkvmerge)
- [x] O arquivo MKV é gerado corretamente ao confirmar - [x] O arquivo MKV é gerado corretamente ao confirmar
@@ -431,6 +440,7 @@ src/
- [x] É possível cancelar a geração em andamento - [x] É possível cancelar a geração em andamento
- [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída) - [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída)
- [x] O flag `-c copy` está sempre presente no comando FFmpeg gerado (verificável via testes unitários) - [x] O flag `-c copy` está sempre presente no comando FFmpeg gerado (verificável via testes unitários)
- [x] O comando FFmpeg/mkvmerge gerado é exibido em painel colapsável na ExecutionPanel (DT-10)
--- ---
+10 -10
View File
@@ -1,7 +1,7 @@
# Implementação — DT-10: Painel de Debug do Comando Gerado # Implementação — DT-10: Painel de Debug do Comando Gerado
**Data:** 01/03/2026 **Data:** 01/03/2026
**Status:** Planejado **Status:** ✅ Implementado
**Prioridade:** Baixa — melhoria de DX para usuário técnico **Prioridade:** Baixa — melhoria de DX para usuário técnico
**Arquivos afetados:** **Arquivos afetados:**
@@ -151,12 +151,12 @@ Ao concluir a implementação:
## Critério de aceitação ## Critério de aceitação
- [ ] O painel "🛠 Comando gerado" aparece na `ExecutionPanel` após a primeira geração - [x] O painel "🛠 Comando gerado" aparece na `ExecutionPanel` após a primeira geração
- [ ] O painel está colapsado por padrão - [x] O painel está colapsado por padrão
- [ ] O conteúdo exibe o binário (`ffmpeg` ou `mkvmerge`) seguido de todos os argumentos - [x] O conteúdo exibe o binário (`ffmpeg` ou `mkvmerge`) seguido de todos os argumentos
- [ ] Argumentos com espaços internos são envolvidos em aspas duplas - [x] Argumentos com espaços internos são envolvidos em aspas duplas
- [ ] O botão "📋 Copiar" coloca o texto no clipboard - [x] O botão "📋 Copiar" coloca o texto no clipboard
- [ ] O comando é exibido também para gerações canceladas - [x] O comando é exibido também para gerações canceladas
- [ ] O painel **não** aparece antes da primeira geração (estado `Idle` inicial) - [x] O painel **não** aparece antes da primeira geração (estado `Idle` inicial)
- [ ] Modo Lote exibe o comando do último item processado - [x] Modo Lote exibe o comando do último item processado
- [ ] `cargo test`33 testes passando sem regressão - [x] `cargo test`68 testes passando sem regressão
+260
View File
@@ -0,0 +1,260 @@
# Implementação — Reordenar Faixas Externas (botões ↑↓)
**Data:** 01/03/2026
**Status:** ✅ Implementado
**Prioridade:** Baixa — melhoria de usabilidade
**Arquivos afetados:**
- `src/domain/entities/project.rs`
- `src/ui/components/added_track_list.rs`
- `src/ui/app.rs`
---
## Objetivo
Permitir que o usuário reordene as faixas externas adicionadas (áudio e legenda) usando botões ↑↓ na lista. A ordem do `Vec<Track>` em `project.tracks` determina diretamente o índice de stream no container MKV final — já que ambos os command builders (`FfmpegCommandBuilder` e `MkvmergeCommandBuilder`) iteram esse Vec posicionalmente a cada `build()`.
---
## Por que botões ↑↓ e não drag-and-drop
| Critério | Botões ↑↓ | Drag-and-drop (egui) |
| ------------------------ | --------------------------------------- | -------------------------------------------- |
| Esforço de implementação | ~1h | ~56h |
| Risco de regressão | Nulo — mudanças aditivas | Baixo, mas requer estado extra no componente |
| Clareza de UX | Explícita, sem ambiguidade | Mais fluido, porém menos óbvio em grids |
| Testabilidade | Método de domínio testável isoladamente | Lógica de UI difícil de testar |
**Conclusão:** botões ↑↓ entregam 100% do valor com ~15% do esforço.
---
## Análise da arquitetura atual
A ordem das faixas externas já é a fonte de verdade do índice no output:
```rust
// FfmpegCommandBuilder::build() — sem cache de índice
let mut ext_idx = external_input_start;
for track in &project.tracks { // ← itera em ordem
args.push("-map".to_string());
// ...
ext_idx += 1;
}
```
```rust
// MkvmergeCommandBuilder::build() — idem
for track in &project.tracks { // ← itera em ordem
// cada arquivo externo vira um input separado
}
```
`TrackId` é opaco (`TrackId(u32)`) — não representa posição, apenas identidade. Uma troca de posição no Vec não invalida nenhum `TrackId` existente.
---
## Passo 1 — Método `move_track` em `Project`
**Arquivo:** `src/domain/entities/project.rs`
Adicionar logo após `remove_track`:
```rust
/// Move uma faixa externa para cima (`delta = -1`) ou para baixo (`delta = 1`).
/// Retorna `true` se a faixa foi encontrada e o movimento era possível.
pub fn move_track(&mut self, id: TrackId, delta: i8) -> bool {
let Some(pos) = self.tracks.iter().position(|t| t.id() == id) else {
return false;
};
let new_pos = pos as i64 + delta as i64;
if new_pos < 0 || new_pos >= self.tracks.len() as i64 {
return false;
}
self.tracks.swap(pos, new_pos as usize);
true
}
```
---
## Passo 2 — Novos eventos em `AddedTrackEvent`
**Arquivo:** `src/ui/components/added_track_list.rs`
```rust
pub enum AddedTrackEvent {
RemoveRequested(TrackId),
MoveUp(TrackId), // ← novo
MoveDown(TrackId), // ← novo
}
```
---
## Passo 3 — Botões ↑↓ na grid
**Arquivo:** `src/ui/components/added_track_list.rs`
Alterar a assinatura do método para receber o índice atual e o total, e adicionar os botões na última coluna.
A grid passa de 4 para 5 colunas (`num_columns(5)`). O cabeçalho ganha uma coluna `""` extra. Cada linha recebe os botões ↑ e ↓, desabilitados na primeira e última posição respectivamente:
```rust
pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
let mut events = Vec::new();
let total = tracks.len();
ui.group(|ui| {
ui.heading("Faixas adicionadas");
if tracks.is_empty() {
ui.label("Nenhuma faixa adicionada.");
return;
}
egui::Grid::new("added_tracks_grid")
.num_columns(5) // ← era 4
.max_col_width(200.0)
.striped(true)
.show(ui, |ui| {
ui.strong("Tipo");
ui.strong("Arquivo");
ui.strong("Idioma");
ui.strong(""); // coluna de ordem
ui.strong(""); // coluna de remover
ui.end_row();
for (idx, track) in tracks.iter().enumerate() {
// ... células existentes (tipo, arquivo, idioma) ...
// ── Botões de ordenação ──
ui.horizontal(|ui| {
ui.add_enabled_ui(idx > 0, |ui| {
if ui.small_button("").clicked() {
events.push(AddedTrackEvent::MoveUp(track.id()));
}
});
ui.add_enabled_ui(idx + 1 < total, |ui| {
if ui.small_button("").clicked() {
events.push(AddedTrackEvent::MoveDown(track.id()));
}
});
});
if ui.small_button("🗑 Remover").clicked() {
events.push(AddedTrackEvent::RemoveRequested(track.id()));
}
ui.end_row();
}
});
});
events
}
```
---
## Passo 4 — Tratar eventos em `app.rs`
**Arquivo:** `src/ui/app.rs`
O trecho que já trata `RemoveRequested` precisa ser expandido:
```rust
let track_events = self.added_track_list.ui(ui, &project.tracks);
for event in track_events {
use crate::ui::components::added_track_list::AddedTrackEvent;
match event {
AddedTrackEvent::RemoveRequested(id) => {
let _ = RemoveTrack::execute(project, id);
}
AddedTrackEvent::MoveUp(id) => {
project.move_track(id, -1);
}
AddedTrackEvent::MoveDown(id) => {
project.move_track(id, 1);
}
}
}
```
---
## Passo 5 — Testes unitários para `move_track`
**Arquivo:** `src/domain/entities/project.rs` — bloco `#[cfg(test)]` existente.
```rust
#[test]
fn move_track_para_cima() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let t2 = audio_track_with_drift(2, 1.0);
let id1 = t1.id();
let id2 = t2.id();
p.tracks.push(t1);
p.tracks.push(t2);
assert!(p.move_track(id2, -1));
assert_eq!(p.tracks[0].id(), id2);
assert_eq!(p.tracks[1].id(), id1);
}
#[test]
fn move_track_para_baixo() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let t2 = audio_track_with_drift(2, 1.0);
let id1 = t1.id();
let id2 = t2.id();
p.tracks.push(t1);
p.tracks.push(t2);
assert!(p.move_track(id1, 1));
assert_eq!(p.tracks[0].id(), id2);
assert_eq!(p.tracks[1].id(), id1);
}
#[test]
fn move_track_limite_superior_ignorado() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let id1 = t1.id();
p.tracks.push(t1);
assert!(!p.move_track(id1, -1)); // já é o primeiro — não move
}
#[test]
fn move_track_limite_inferior_ignorado() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let id1 = t1.id();
p.tracks.push(t1);
assert!(!p.move_track(id1, 1)); // já é o último — não move
}
```
---
## Escopo e limitações
- Somente faixas **externas** (`project.tracks`) são reordenáveis. Faixas existentes (`project.existing_tracks`, lidas via ffprobe) permanecem na ordem detectada — essa restrição deve ser comunicada visualmente (ex: tooltip ou nota abaixo da lista de faixas adicionadas).
- A ordenação é persistida automaticamente via RF-14 (sessão salva em JSON inclui o Vec na nova ordem).
- Sem impacto no modo Lote: cada `BatchItem` tem seu próprio `Project` independente; a mesma lógica se aplica.
---
## Checklist de implementação
- [x] `Project::move_track()` adicionado e testado (4 testes novos)
- [x] `AddedTrackEvent` com variantes `MoveUp` e `MoveDown`
- [x] Grid de 5 colunas com botões ↑↓ desabilitados nas bordas
- [x] Handler em `app.rs` tratando os dois novos eventos
- [x] `cargo test` — 72 testes passando, 0 falhas
- [x] `cargo check` sem warnings (3 warnings pré-existentes não relacionados)
+64
View File
@@ -69,6 +69,20 @@ impl Project {
self.tracks.len() < before self.tracks.len() < before
} }
/// Move uma faixa externa para cima (`delta = -1`) ou para baixo (`delta = 1`).
/// Retorna `true` se a faixa foi encontrada e o movimento era possível.
pub fn move_track(&mut self, id: TrackId, delta: i8) -> bool {
let Some(pos) = self.tracks.iter().position(|t| t.id() == id) else {
return false;
};
let new_pos = pos as i64 + delta as i64;
if new_pos < 0 || new_pos >= self.tracks.len() as i64 {
return false;
}
self.tracks.swap(pos, new_pos as usize);
true
}
/// Retorna `true` se qualquer faixa (externa ou existente) possui `drift_scale != 1.0`, /// Retorna `true` se qualquer faixa (externa ou existente) possui `drift_scale != 1.0`,
/// indicando que o projeto deve usar o pipeline mkvmerge em vez do FFmpeg. /// indicando que o projeto deve usar o pipeline mkvmerge em vez do FFmpeg.
pub fn needs_mkvmerge(&self) -> bool { pub fn needs_mkvmerge(&self) -> bool {
@@ -195,4 +209,54 @@ mod tests {
assert!(!p.toggle_existing_track_excluded(id)); assert!(!p.toggle_existing_track_excluded(id));
assert!(!p.existing_tracks[0].excluded); assert!(!p.existing_tracks[0].excluded);
} }
#[test]
fn move_track_para_cima() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let t2 = audio_track_with_drift(2, 1.0);
let id1 = t1.id();
let id2 = t2.id();
p.tracks.push(t1);
p.tracks.push(t2);
assert!(p.move_track(id2, -1));
assert_eq!(p.tracks[0].id(), id2);
assert_eq!(p.tracks[1].id(), id1);
}
#[test]
fn move_track_para_baixo() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let t2 = audio_track_with_drift(2, 1.0);
let id1 = t1.id();
let id2 = t2.id();
p.tracks.push(t1);
p.tracks.push(t2);
assert!(p.move_track(id1, 1));
assert_eq!(p.tracks[0].id(), id2);
assert_eq!(p.tracks[1].id(), id1);
}
#[test]
fn move_track_limite_superior_ignorado() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let id1 = t1.id();
p.tracks.push(t1);
assert!(!p.move_track(id1, -1)); // já é o primeiro — não move
}
#[test]
fn move_track_limite_inferior_ignorado() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let id1 = t1.id();
p.tracks.push(t1);
assert!(!p.move_track(id1, 1)); // já é o último — não move
}
} }
+34 -2
View File
@@ -245,6 +245,19 @@ impl App {
} else { } else {
FfmpegCommandBuilder::build(&project) FfmpegCommandBuilder::build(&project)
}; };
// Formatar comando legível para o painel de debug
let binary = if uses_mkvmerge { "mkvmerge" } else { "ffmpeg" };
let cmd_str = format!(
"{} {}",
binary,
args.iter()
.map(|a| if a.contains(' ') { format!("\"{}\"", a) } else { a.clone() })
.collect::<Vec<_>>()
.join(" ")
);
self.execution_panel.last_command = Some(cmd_str);
let (tx, rx) = mpsc::channel::<BackgroundMsg>(); let (tx, rx) = mpsc::channel::<BackgroundMsg>();
self.bg_rx = Some(rx); self.bg_rx = Some(rx);
self.execution_panel.state = ExecutionState::Running; self.execution_panel.state = ExecutionState::Running;
@@ -311,6 +324,19 @@ impl App {
} else { } else {
FfmpegCommandBuilder::build(&project) FfmpegCommandBuilder::build(&project)
}; };
// Formatar comando legível para o painel de debug do item de lote
let binary = if uses_mkvmerge { "mkvmerge" } else { "ffmpeg" };
let cmd_str = format!(
"{} {}",
binary,
args.iter()
.map(|a| if a.contains(' ') { format!("\"{}\"", a) } else { a.clone() })
.collect::<Vec<_>>()
.join(" ")
);
self.batch_items[index].last_command = Some(cmd_str);
let (tx, rx) = mpsc::channel::<BackgroundMsg>(); let (tx, rx) = mpsc::channel::<BackgroundMsg>();
self.bg_rx = Some(rx); self.bg_rx = Some(rx);
@@ -779,13 +805,19 @@ impl eframe::App for App {
ui.add_space(8.0); ui.add_space(8.0);
// ── Faixas externas adicionadas ─────────────────────── // ── Faixas externas adicionadas ───────────────────────
let remove_events = self.added_track_list.ui(ui, &project.tracks); let track_events = self.added_track_list.ui(ui, &project.tracks);
for event in remove_events { for event in track_events {
use crate::ui::components::added_track_list::AddedTrackEvent; use crate::ui::components::added_track_list::AddedTrackEvent;
match event { match event {
AddedTrackEvent::RemoveRequested(id) => { AddedTrackEvent::RemoveRequested(id) => {
let _ = RemoveTrack::execute(project, id); let _ = RemoveTrack::execute(project, id);
} }
AddedTrackEvent::MoveUp(id) => {
project.move_track(id, -1);
}
AddedTrackEvent::MoveDown(id) => {
project.move_track(id, 1);
}
} }
} }
+29 -3
View File
@@ -5,6 +5,8 @@ use eframe::egui;
/// Evento emitido por interações com a lista de faixas externas adicionadas. /// Evento emitido por interações com a lista de faixas externas adicionadas.
pub enum AddedTrackEvent { pub enum AddedTrackEvent {
RemoveRequested(TrackId), RemoveRequested(TrackId),
MoveUp(TrackId),
MoveDown(TrackId),
} }
/// Lista as faixas externas adicionadas pelo usuário e permite removê-las. /// Lista as faixas externas adicionadas pelo usuário e permite removê-las.
@@ -15,9 +17,10 @@ impl AddedTrackList {
AddedTrackList AddedTrackList
} }
/// Renderiza a lista. Retorna eventos de remoção. /// Renderiza a lista. Retorna eventos de interação (remoção e reordenação).
pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> { pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
let total = tracks.len();
ui.group(|ui| { ui.group(|ui| {
ui.heading("Faixas adicionadas"); ui.heading("Faixas adicionadas");
@@ -28,17 +31,18 @@ impl AddedTrackList {
} }
egui::Grid::new("added_tracks_grid") egui::Grid::new("added_tracks_grid")
.num_columns(4) .num_columns(5)
.max_col_width(200.0) .max_col_width(200.0)
.striped(true) .striped(true)
.show(ui, |ui| { .show(ui, |ui| {
ui.strong("Tipo"); ui.strong("Tipo");
ui.strong("Arquivo"); ui.strong("Arquivo");
ui.strong("Idioma"); ui.strong("Idioma");
ui.strong("Ordem");
ui.strong(""); ui.strong("");
ui.end_row(); ui.end_row();
for track in tracks { for (idx, track) in tracks.iter().enumerate() {
let (kind_label, file_name, language) = match track { let (kind_label, file_name, language) = match track {
Track::Audio(t) => ( Track::Audio(t) => (
"Áudio", "Áudio",
@@ -67,6 +71,28 @@ impl AddedTrackList {
.on_hover_text(track_full_path(track)); .on_hover_text(track_full_path(track));
ui.label(language); ui.label(language);
// ── Botões de reordenação ──────────────────────────
ui.horizontal(|ui| {
ui.add_enabled_ui(idx > 0, |ui| {
if ui
.small_button("")
.on_hover_text("Mover para cima")
.clicked()
{
events.push(AddedTrackEvent::MoveUp(track.id()));
}
});
ui.add_enabled_ui(idx + 1 < total, |ui| {
if ui
.small_button("")
.on_hover_text("Mover para baixo")
.clicked()
{
events.push(AddedTrackEvent::MoveDown(track.id()));
}
});
});
if ui.small_button("🗑 Remover").clicked() { if ui.small_button("🗑 Remover").clicked() {
events.push(AddedTrackEvent::RemoveRequested(track.id())); events.push(AddedTrackEvent::RemoveRequested(track.id()));
} }
+17 -1
View File
@@ -17,6 +17,8 @@ pub struct BatchItem {
pub project: Project, pub project: Project,
pub state: ExecutionState, pub state: ExecutionState,
pub log_lines: Vec<String>, pub log_lines: Vec<String>,
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração deste item.
pub last_command: Option<String>,
} }
impl BatchItem { impl BatchItem {
@@ -25,6 +27,7 @@ impl BatchItem {
project, project,
state: ExecutionState::Idle, state: ExecutionState::Idle,
log_lines: Vec::new(), log_lines: Vec::new(),
last_command: None,
} }
} }
} }
@@ -254,7 +257,20 @@ impl BatchPanel {
}); });
}); });
} }
}); if let Some(cmd) = &item.last_command {
ui.collapsing("🛠 Comando gerado", |ui| {
let mut cmd_str = cmd.as_str();
ui.add(
egui::TextEdit::multiline(&mut cmd_str)
.desired_rows(3)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
if ui.small_button("📋 Copiar").clicked() {
ui.output_mut(|o| o.copied_text = cmd.clone());
}
});
} });
}); // push_id }); // push_id
ui.add_space(4.0); ui.add_space(4.0);
} }
+20
View File
@@ -15,6 +15,9 @@ pub enum ExecutionState {
pub struct ExecutionPanel { pub struct ExecutionPanel {
pub state: ExecutionState, pub state: ExecutionState,
pub log_lines: Vec<String>, pub log_lines: Vec<String>,
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração.
/// Exemplo: "ffmpeg -i input.mkv -c copy ... output.mkv"
pub last_command: Option<String>,
} }
impl ExecutionPanel { impl ExecutionPanel {
@@ -22,6 +25,7 @@ impl ExecutionPanel {
ExecutionPanel { ExecutionPanel {
state: ExecutionState::Idle, state: ExecutionState::Idle,
log_lines: Vec::new(), log_lines: Vec::new(),
last_command: None,
} }
} }
@@ -103,6 +107,22 @@ impl ExecutionPanel {
}); });
}); });
} }
// Painel de debug: comando gerado
if let Some(cmd) = &self.last_command {
ui.collapsing("🛠 Comando gerado", |ui| {
let mut cmd_str = cmd.as_str();
ui.add(
egui::TextEdit::multiline(&mut cmd_str)
.desired_rows(3)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
if ui.small_button("📋 Copiar").clicked() {
ui.output_mut(|o| o.copied_text = cmd.clone());
}
});
}
}); });
(generate_clicked, cancel_clicked) (generate_clicked, cancel_clicked)