diff --git a/PRD.md b/PRD.md index 1e297ab..a95c840 100644 --- a/PRD.md +++ b/PRD.md @@ -1,9 +1,9 @@ # PRD — Simple Multimedia Track Audio Editor -**Versão:** 1.4 -**Data:** 01/03/2026 -**Status:** v1.0 funcional — Fases 1–9 concluídas; 33 testes passando; aplicação executável -**Revisão:** v1.4 — Adicionado RF-15 (Excluir faixa existente do output) +**Versão:** 1.5 +**Data:** 02/03/2026 +**Status:** v1.0 funcional — Fases 1–9 concluídas; 72 testes passando; aplicação executável +**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` - 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 @@ -422,6 +431,7 @@ src/ - [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 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] É possível definir fator de correção de drift por faixa (RF-12; requer mkvmerge) - [x] O arquivo MKV é gerado corretamente ao confirmar diff --git a/docs/impl-reorder-external-tracks.md b/docs/impl-reorder-external-tracks.md new file mode 100644 index 0000000..c4dfa6d --- /dev/null +++ b/docs/impl-reorder-external-tracks.md @@ -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` 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 | ~5–6h | +| 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 { + 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) diff --git a/src/domain/entities/project.rs b/src/domain/entities/project.rs index 04ef24b..7390190 100644 --- a/src/domain/entities/project.rs +++ b/src/domain/entities/project.rs @@ -69,6 +69,20 @@ impl Project { 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`, /// indicando que o projeto deve usar o pipeline mkvmerge em vez do FFmpeg. pub fn needs_mkvmerge(&self) -> bool { @@ -195,4 +209,54 @@ mod tests { assert!(!p.toggle_existing_track_excluded(id)); 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 + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index b78154f..97d1bd1 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -805,13 +805,19 @@ impl eframe::App for App { ui.add_space(8.0); // ── Faixas externas adicionadas ─────────────────────── - let remove_events = self.added_track_list.ui(ui, &project.tracks); - for event in remove_events { + 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); + } } } diff --git a/src/ui/components/added_track_list.rs b/src/ui/components/added_track_list.rs index db92bd5..5577053 100644 --- a/src/ui/components/added_track_list.rs +++ b/src/ui/components/added_track_list.rs @@ -5,6 +5,8 @@ use eframe::egui; /// Evento emitido por interações com a lista de faixas externas adicionadas. pub enum AddedTrackEvent { RemoveRequested(TrackId), + MoveUp(TrackId), + MoveDown(TrackId), } /// Lista as faixas externas adicionadas pelo usuário e permite removê-las. @@ -15,9 +17,10 @@ impl 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 { let mut events = Vec::new(); + let total = tracks.len(); ui.group(|ui| { ui.heading("Faixas adicionadas"); @@ -28,17 +31,18 @@ impl AddedTrackList { } egui::Grid::new("added_tracks_grid") - .num_columns(4) + .num_columns(5) .max_col_width(200.0) .striped(true) .show(ui, |ui| { ui.strong("Tipo"); ui.strong("Arquivo"); ui.strong("Idioma"); + ui.strong("Ordem"); ui.strong(""); ui.end_row(); - for track in tracks { + for (idx, track) in tracks.iter().enumerate() { let (kind_label, file_name, language) = match track { Track::Audio(t) => ( "Áudio", @@ -67,6 +71,28 @@ impl AddedTrackList { .on_hover_text(track_full_path(track)); 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() { events.push(AddedTrackEvent::RemoveRequested(track.id())); }