feat: adiciona funcionalidade de remoção de faixas externas e atualiza o layout da UI
This commit is contained in:
+18
-9
@@ -1,7 +1,7 @@
|
|||||||
# Progresso de Implementação
|
# Progresso de Implementação
|
||||||
|
|
||||||
**Data:** 28/02/2026
|
**Data:** 28/02/2026
|
||||||
**Status:** Fases 1–6 concluídas + exportação de faixa implementada — compilando, testes passando, aplicação executável
|
**Status:** Fases 1–6 concluídas + exportação de faixa + exibição/remoção de faixas adicionadas + layout responsivo + progresso em tempo real — compilando, testes passando, aplicação executável
|
||||||
**Referência:** DEVELOPMENT_PLAN.md v1.0
|
**Referência:** DEVELOPMENT_PLAN.md v1.0
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -11,8 +11,11 @@
|
|||||||
Todas as 6 fases do plano de desenvolvimento foram implementadas. O projeto compila sem erros,
|
Todas as 6 fases do plano de desenvolvimento foram implementadas. O projeto compila sem erros,
|
||||||
31 testes unitários passam e a aplicação pode ser executada com `cargo run`.
|
31 testes unitários passam e a aplicação pode ser executada com `cargo run`.
|
||||||
|
|
||||||
Após a conclusão das fases, foi implementada a funcionalidade de exportação de faixa de áudio
|
Após a conclusão das fases, foram implementadas funcionalidades adicionais:
|
||||||
ou legenda diretamente da lista de faixas existentes.
|
- Exportação de faixa de áudio ou legenda diretamente da lista de faixas existentes
|
||||||
|
- Exibição das faixas externas adicionadas com opção de remoção individual
|
||||||
|
- 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ src/
|
|||||||
│ ├── adjust_sync.rs — altera SyncOffset de faixa externa
|
│ ├── adjust_sync.rs — altera SyncOffset de faixa externa
|
||||||
│ ├── edit_existing_track_sync.rs — altera SyncOffset de faixa existente
|
│ ├── edit_existing_track_sync.rs — altera SyncOffset de faixa existente
|
||||||
│ ├── export_track.rs — exporta faixa de áudio ou legenda para arquivo
|
│ ├── export_track.rs — exporta faixa de áudio ou legenda para arquivo
|
||||||
|
│ ├── remove_track.rs — remove faixa externa do projeto pelo TrackId
|
||||||
│ ├── set_track_language.rs — altera idioma de faixa externa
|
│ ├── set_track_language.rs — altera idioma de faixa externa
|
||||||
│ └── generate_output.rs — constrói comando e delega ao MediaProcessorPort
|
│ └── generate_output.rs — constrói comando e delega ao MediaProcessorPort
|
||||||
│
|
│
|
||||||
@@ -91,8 +95,7 @@ src/
|
|||||||
├── mod.rs
|
├── mod.rs
|
||||||
├── video_selector.rs — seleção do vídeo base
|
├── video_selector.rs — seleção do vídeo base
|
||||||
├── output_selector.rs — seleção do arquivo de saída (.mkv)
|
├── output_selector.rs — seleção do arquivo de saída (.mkv)
|
||||||
├── existing_track_list.rs — lista faixas detectadas + edição de offset
|
├── existing_track_list.rs — lista faixas detectadas + edição de offset│ ├── added_track_list.rs — lista faixas externas adicionadas + botão remover ├── add_audio_track_form.rs — formulário: áudio externo
|
||||||
├── add_audio_track_form.rs — formulário: áudio externo
|
|
||||||
├── 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
|
||||||
@@ -101,7 +104,7 @@ src/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testes Unitários (31/31 passando)
|
## Testes Unitários (33/33 passando)
|
||||||
|
|
||||||
### Domain — Value Objects
|
### Domain — Value Objects
|
||||||
| Teste | Resultado |
|
| Teste | Resultado |
|
||||||
@@ -154,6 +157,12 @@ src/
|
|||||||
| `export_track::exporta_legenda_output_e_ultimo_argumento` | ✅ |
|
| `export_track::exporta_legenda_output_e_ultimo_argumento` | ✅ |
|
||||||
| `export_track::rejeita_faixa_de_video` — retorna erro para `TrackKind::Video` | ✅ |
|
| `export_track::rejeita_faixa_de_video` — retorna erro para `TrackKind::Video` | ✅ |
|
||||||
|
|
||||||
|
### Application — RemoveTrack
|
||||||
|
| Teste | Resultado |
|
||||||
|
|-------|-----------|
|
||||||
|
| `remove_track::remove_faixa_existente` — faixa removida do Vec | ✅ |
|
||||||
|
| `remove_track::erro_se_id_inexistente` — retorna erro se id não existe | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dependências (Cargo.toml)
|
## Dependências (Cargo.toml)
|
||||||
@@ -184,8 +193,8 @@ rfd = "0.14"
|
|||||||
|
|
||||||
### Funcional
|
### Funcional
|
||||||
- [x] Exportar faixa de áudio ou legenda existente para arquivo separado
|
- [x] Exportar faixa de áudio ou legenda existente para arquivo separado
|
||||||
- [ ] Exibição de faixas externas adicionadas com opção de remover
|
- [x] Exibição de faixas externas adicionadas com opção de remover
|
||||||
- [ ] Progresso em tempo real via `run_ffmpeg_async` (infrastructure já implementada, UI usa thread bloqueante por ora)
|
- [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
|
||||||
- [ ] Exibição do comando gerado em modo debug
|
- [ ] Exibição do comando gerado em modo debug
|
||||||
|
|
||||||
### Qualidade
|
### Qualidade
|
||||||
@@ -194,7 +203,7 @@ rfd = "0.14"
|
|||||||
- [ ] Corrigir warnings de `unused` e `dead_code` remanescentes
|
- [ ] Corrigir warnings de `unused` e `dead_code` remanescentes
|
||||||
|
|
||||||
### UI
|
### UI
|
||||||
- [ ] Layout responsivo mais refinado (scroll nas listas longas)
|
- [x] Layout responsivo mais refinado
|
||||||
- [ ] Persistência do estado do projeto em disco (serde já disponível)
|
- [ ] Persistência do estado do projeto em disco (serde já disponível)
|
||||||
- [ ] Mensagens de erro mais amigáveis na UI
|
- [ ] Mensagens de erro mais amigáveis na UI
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ pub mod edit_existing_track_sync;
|
|||||||
pub mod export_track;
|
pub mod export_track;
|
||||||
pub mod generate_output;
|
pub mod generate_output;
|
||||||
pub mod load_media_info;
|
pub mod load_media_info;
|
||||||
|
pub mod remove_track;
|
||||||
pub mod set_track_language;
|
pub mod set_track_language;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use crate::domain::entities::Project;
|
||||||
|
use crate::domain::value_objects::TrackId;
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
|
/// Remove uma faixa externa do projeto pelo seu TrackId.
|
||||||
|
pub struct RemoveTrack;
|
||||||
|
|
||||||
|
impl RemoveTrack {
|
||||||
|
pub fn execute(project: &mut Project, id: TrackId) -> Result<()> {
|
||||||
|
if project.remove_track(id) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Faixa com id {:?} não encontrada.", id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::domain::entities::{AudioTrack, MkvOutput, Project, Track, VideoFile};
|
||||||
|
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
|
||||||
|
|
||||||
|
fn make_project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
VideoFile::new(FilePath::from("input.mkv")),
|
||||||
|
MkvOutput::new(FilePath::from("output.mkv")),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_faixa_existente() {
|
||||||
|
let mut project = make_project();
|
||||||
|
let id = project.next_track_id();
|
||||||
|
project.tracks.push(Track::Audio(AudioTrack::new(
|
||||||
|
id,
|
||||||
|
FilePath::from("audio.aac"),
|
||||||
|
SyncOffset::from_ms(0),
|
||||||
|
TrackLanguage::new("por").unwrap(),
|
||||||
|
)));
|
||||||
|
assert_eq!(project.tracks.len(), 1);
|
||||||
|
|
||||||
|
RemoveTrack::execute(&mut project, id).unwrap();
|
||||||
|
assert!(project.tracks.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn erro_se_id_inexistente() {
|
||||||
|
let mut project = make_project();
|
||||||
|
let result = RemoveTrack::execute(&mut project, TrackId::new(99));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@ impl Project {
|
|||||||
pub fn find_existing_track_mut(&mut self, id: TrackId) -> Option<&mut MediaTrackInfo> {
|
pub fn find_existing_track_mut(&mut self, id: TrackId) -> Option<&mut MediaTrackInfo> {
|
||||||
self.existing_tracks.iter_mut().find(|t| t.id == id)
|
self.existing_tracks.iter_mut().find(|t| t.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove uma faixa externa pelo TrackId. Retorna true se a faixa foi encontrada e removida.
|
||||||
|
pub fn remove_track(&mut self, id: TrackId) -> bool {
|
||||||
|
let before = self.tracks.len();
|
||||||
|
self.tracks.retain(|t| t.id() != id);
|
||||||
|
self.tracks.len() < before
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
/// Executa o FFmpeg de forma assíncrona, capturando stderr em tempo real.
|
/// Executa o FFmpeg de forma assíncrona, capturando stderr em tempo real.
|
||||||
/// As linhas de saída são enviadas pelo canal `progress_tx`.
|
/// As linhas de saída são enviadas pelo canal `progress_tx` (std::sync::mpsc).
|
||||||
pub async fn run_ffmpeg_async(
|
pub async fn run_ffmpeg_async(args: Vec<String>, progress_tx: Sender<String>) -> Result<()> {
|
||||||
args: Vec<String>,
|
|
||||||
progress_tx: mpsc::UnboundedSender<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut child = Command::new("ffmpeg")
|
let mut child = Command::new("ffmpeg")
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
@@ -47,7 +44,12 @@ pub fn check_binary_available(binary: &str) -> Result<()> {
|
|||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
.status()
|
.status()
|
||||||
.map_err(|_| anyhow!("'{}' não encontrado no PATH. Por favor, instale o FFmpeg.", binary))?;
|
.map_err(|_| {
|
||||||
|
anyhow!(
|
||||||
|
"'{}' não encontrado no PATH. Por favor, instale o FFmpeg.",
|
||||||
|
binary
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@ fn main() -> eframe::Result<()> {
|
|||||||
let native_options = eframe::NativeOptions {
|
let native_options = eframe::NativeOptions {
|
||||||
viewport: eframe::egui::ViewportBuilder::default()
|
viewport: eframe::egui::ViewportBuilder::default()
|
||||||
.with_title("Editor de Faixas de Mídia")
|
.with_title("Editor de Faixas de Mídia")
|
||||||
.with_inner_size([900.0, 700.0])
|
.with_inner_size([1100.0, 720.0])
|
||||||
.with_min_inner_size([600.0, 400.0]),
|
.with_min_inner_size([700.0, 500.0]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+163
-60
@@ -1,20 +1,24 @@
|
|||||||
use eframe::egui;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
|
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
|
||||||
use crate::adapters::filesystem::file_picker::FilePickerAdapter;
|
use crate::adapters::filesystem::file_picker::FilePickerAdapter;
|
||||||
use crate::application::ports::MediaProcessorPort;
|
|
||||||
use crate::application::use_cases::{
|
use crate::application::use_cases::{
|
||||||
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
|
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
|
||||||
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
|
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
|
||||||
load_media_info::LoadMediaInfo,
|
load_media_info::LoadMediaInfo, remove_track::RemoveTrack,
|
||||||
};
|
};
|
||||||
use crate::domain::entities::{MkvOutput, Project, VideoFile};
|
use crate::domain::entities::{MkvOutput, Project, VideoFile};
|
||||||
use crate::domain::value_objects::FilePath;
|
use crate::domain::value_objects::FilePath;
|
||||||
|
use crate::infrastructure::process::run_ffmpeg_async;
|
||||||
use crate::ui::components::{
|
use crate::ui::components::{
|
||||||
add_audio_track_form::AddAudioTrackForm, add_subtitle_form::AddSubtitleForm,
|
add_audio_track_form::AddAudioTrackForm,
|
||||||
execution_panel::{ExecutionPanel, ExecutionState}, existing_track_list::ExistingTrackList,
|
add_subtitle_form::AddSubtitleForm,
|
||||||
output_selector::OutputSelector, video_selector::VideoSelector,
|
added_track_list::AddedTrackList,
|
||||||
|
execution_panel::{ExecutionPanel, ExecutionState},
|
||||||
|
existing_track_list::ExistingTrackList,
|
||||||
|
output_selector::OutputSelector,
|
||||||
|
video_selector::VideoSelector,
|
||||||
};
|
};
|
||||||
|
use eframe::egui;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
/// Mensagens enviadas do background thread para a UI.
|
/// Mensagens enviadas do background thread para a UI.
|
||||||
enum BackgroundMsg {
|
enum BackgroundMsg {
|
||||||
@@ -33,6 +37,7 @@ pub struct App {
|
|||||||
video_selector: VideoSelector,
|
video_selector: VideoSelector,
|
||||||
output_selector: OutputSelector,
|
output_selector: OutputSelector,
|
||||||
existing_track_list: ExistingTrackList,
|
existing_track_list: ExistingTrackList,
|
||||||
|
added_track_list: AddedTrackList,
|
||||||
add_audio_form: AddAudioTrackForm,
|
add_audio_form: AddAudioTrackForm,
|
||||||
add_subtitle_form: AddSubtitleForm,
|
add_subtitle_form: AddSubtitleForm,
|
||||||
execution_panel: ExecutionPanel,
|
execution_panel: ExecutionPanel,
|
||||||
@@ -47,7 +52,8 @@ pub struct App {
|
|||||||
impl App {
|
impl App {
|
||||||
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
// Verifica dependências na inicialização
|
// Verifica dependências na inicialização
|
||||||
let global_error = crate::infrastructure::process::validate_dependencies().err()
|
let global_error = crate::infrastructure::process::validate_dependencies()
|
||||||
|
.err()
|
||||||
.map(|e| e.to_string());
|
.map(|e| e.to_string());
|
||||||
|
|
||||||
App {
|
App {
|
||||||
@@ -57,6 +63,7 @@ impl App {
|
|||||||
video_selector: VideoSelector::new(),
|
video_selector: VideoSelector::new(),
|
||||||
output_selector: OutputSelector::new(),
|
output_selector: OutputSelector::new(),
|
||||||
existing_track_list: ExistingTrackList::new(),
|
existing_track_list: ExistingTrackList::new(),
|
||||||
|
added_track_list: AddedTrackList::new(),
|
||||||
add_audio_form: AddAudioTrackForm::new(),
|
add_audio_form: AddAudioTrackForm::new(),
|
||||||
add_subtitle_form: AddSubtitleForm::new(),
|
add_subtitle_form: AddSubtitleForm::new(),
|
||||||
execution_panel: ExecutionPanel::new(),
|
execution_panel: ExecutionPanel::new(),
|
||||||
@@ -81,7 +88,8 @@ impl App {
|
|||||||
let probe = FfprobeGateway;
|
let probe = FfprobeGateway;
|
||||||
match LoadMediaInfo::execute(&mut project, &probe) {
|
match LoadMediaInfo::execute(&mut project, &probe) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.existing_track_list.sync_tracks(&project.existing_tracks);
|
self.existing_track_list
|
||||||
|
.sync_tracks(&project.existing_tracks);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.execution_panel.state = ExecutionState::Error(e.to_string());
|
self.execution_panel.state = ExecutionState::Error(e.to_string());
|
||||||
@@ -127,7 +135,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inicia geração do MKV em background thread.
|
/// Inicia geração do MKV em background thread com progresso em tempo real.
|
||||||
fn start_generation(&mut self, ctx: egui::Context) {
|
fn start_generation(&mut self, ctx: egui::Context) {
|
||||||
let project = match &self.project {
|
let project = match &self.project {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
@@ -141,8 +149,27 @@ impl App {
|
|||||||
self.execution_panel.log_lines.clear();
|
self.execution_panel.log_lines.clear();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let gateway = FfmpegGateway;
|
// Canal std para receber linhas de log do run_ffmpeg_async
|
||||||
match gateway.execute(args) {
|
let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
|
||||||
|
|
||||||
|
// Thread auxiliar: encaminha cada linha de log para o canal da UI
|
||||||
|
let fwd_tx = tx.clone();
|
||||||
|
let fwd_ctx = ctx.clone();
|
||||||
|
let fwd_handle = std::thread::spawn(move || {
|
||||||
|
for line in log_rx {
|
||||||
|
let _ = fwd_tx.send(BackgroundMsg::LogLine(line));
|
||||||
|
fwd_ctx.request_repaint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio");
|
||||||
|
let result = rt.block_on(run_ffmpeg_async(args, log_tx));
|
||||||
|
|
||||||
|
// Aguarda o encaminhador consumir todas as linhas pendentes
|
||||||
|
// antes de enviar Done/Error, garantindo ordem correta no log
|
||||||
|
let _ = fwd_handle.join();
|
||||||
|
|
||||||
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let _ = tx.send(BackgroundMsg::Done);
|
let _ = tx.send(BackgroundMsg::Done);
|
||||||
}
|
}
|
||||||
@@ -163,20 +190,50 @@ impl eframe::App for App {
|
|||||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
// ── Cabeçalho (fixo no topo) ──────────────────────────────────────────
|
||||||
// Erro global de dependências
|
egui::TopBottomPanel::top("header_panel")
|
||||||
|
.exact_height(if self.global_error.is_some() {
|
||||||
|
58.0
|
||||||
|
} else {
|
||||||
|
38.0
|
||||||
|
})
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.heading("Editor de Faixas de Mídia");
|
||||||
if let Some(ref err) = self.global_error {
|
if let Some(ref err) = self.global_error {
|
||||||
|
ui.add_space(2.0);
|
||||||
ui.colored_label(
|
ui.colored_label(
|
||||||
egui::Color32::RED,
|
egui::Color32::RED,
|
||||||
format!("⚠ Dependência ausente: {}", err),
|
format!("⚠ Dependência ausente: {}", err),
|
||||||
);
|
);
|
||||||
ui.separator();
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ui.heading("Editor de Faixas de Mídia");
|
// ── 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();
|
||||||
|
if self.execution_panel.ui(ui, can_generate) {
|
||||||
|
let ctx_clone = ctx.clone();
|
||||||
|
self.start_generation(ctx_clone);
|
||||||
|
}
|
||||||
|
ui.add_space(4.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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| {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
// ── Seleção de vídeo ──────────────────────────────────────────────
|
// ── Seleção de vídeo ──────────────────────────────────
|
||||||
if let Some(path) = self.video_selector.ui(ui) {
|
if let Some(path) = self.video_selector.ui(ui) {
|
||||||
self.pending_source = Some(path);
|
self.pending_source = Some(path);
|
||||||
if self.pending_output.is_some() {
|
if self.pending_output.is_some() {
|
||||||
@@ -184,9 +241,9 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(4.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
// ── Seleção de saída ──────────────────────────────────────────────
|
// ── Seleção de saída ──────────────────────────────────
|
||||||
if let Some(path) = self.output_selector.ui(ui) {
|
if let Some(path) = self.output_selector.ui(ui) {
|
||||||
self.pending_output = Some(path);
|
self.pending_output = Some(path);
|
||||||
if self.pending_source.is_some() {
|
if self.pending_source.is_some() {
|
||||||
@@ -194,30 +251,50 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.project.is_none() {
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.separator();
|
||||||
ui.add_space(8.0);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Painel central: faixas e formulários ─────────────────────────────
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
if let Some(project) = &mut self.project {
|
if let Some(project) = &mut self.project {
|
||||||
// ── Faixas existentes ─────────────────────────────────────────
|
// ── Faixas existentes ─────────────────────────────────
|
||||||
let events =
|
let events = self.existing_track_list.ui(ui, &project.existing_tracks);
|
||||||
self.existing_track_list.ui(ui, &project.existing_tracks);
|
|
||||||
for event in events {
|
for event in events {
|
||||||
use crate::ui::components::existing_track_list::ExistingTrackEvent;
|
|
||||||
use crate::domain::entities::TrackKind;
|
use crate::domain::entities::TrackKind;
|
||||||
|
use crate::ui::components::existing_track_list::ExistingTrackEvent;
|
||||||
match event {
|
match event {
|
||||||
ExistingTrackEvent::OffsetChanged(id, offset) => {
|
ExistingTrackEvent::OffsetChanged(id, offset) => {
|
||||||
let _ = EditExistingTrackSync::execute(project, id, offset);
|
let _ = EditExistingTrackSync::execute(project, id, offset);
|
||||||
}
|
}
|
||||||
ExistingTrackEvent::ExportRequested(id) => {
|
ExistingTrackEvent::ExportRequested(id) => {
|
||||||
// Localiza a faixa pelo id
|
if let Some(track) =
|
||||||
if let Some(track) = project
|
project.existing_tracks.iter().find(|t| t.id == id).cloned()
|
||||||
.existing_tracks
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.id == id)
|
|
||||||
.cloned()
|
|
||||||
{
|
{
|
||||||
let maybe_output = match track.kind {
|
let maybe_output = match track.kind {
|
||||||
TrackKind::Audio => FilePickerAdapter::save_audio(&track.codec),
|
TrackKind::Audio => {
|
||||||
TrackKind::Subtitle => FilePickerAdapter::save_subtitle(&track.codec),
|
FilePickerAdapter::save_audio(&track.codec)
|
||||||
|
}
|
||||||
|
TrackKind::Subtitle => {
|
||||||
|
FilePickerAdapter::save_subtitle(&track.codec)
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(output_path) = maybe_output {
|
if let Some(output_path) = maybe_output {
|
||||||
@@ -249,39 +326,65 @@ impl eframe::App for App {
|
|||||||
|
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
// ── Adicionar áudio ───────────────────────────────────────────
|
// ── Formulários: lado a lado se largura >= 600 px ─────
|
||||||
if let Some(req) = self.add_audio_form.ui(ui) {
|
// Executa o render fora do bloco de borrow de `project`
|
||||||
let _ = AddAudioTrack::execute(project, req.path, req.offset, req.language);
|
// e coleta as requisições para aplicar em seguida.
|
||||||
}
|
let wide = ui.available_width() >= 600.0;
|
||||||
|
|
||||||
ui.add_space(4.0);
|
let (audio_req, subtitle_req) = if wide {
|
||||||
|
// Renderiza dois painéis horizontais de largura igual
|
||||||
|
let available = ui.available_width();
|
||||||
|
let col_w = (available - ui.spacing().item_spacing.x) / 2.0;
|
||||||
|
|
||||||
// ── Adicionar legenda ─────────────────────────────────────────
|
let audio_req = ui
|
||||||
if let Some(req) = self.add_subtitle_form.ui(ui) {
|
.allocate_ui_with_layout(
|
||||||
let _ = AddSubtitle::execute(project, req.path, req.offset, req.language);
|
egui::Vec2::new(col_w, 0.0),
|
||||||
}
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|
|ui| self.add_audio_form.ui(ui),
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// ── Painel de execução ────────────────────────────────────────
|
|
||||||
let can_generate =
|
|
||||||
self.pending_source.is_some() && self.pending_output.is_some();
|
|
||||||
if self.execution_panel.ui(ui, can_generate) {
|
|
||||||
let ctx_clone = ctx.clone();
|
|
||||||
self.start_generation(ctx_clone);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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(),
|
.inner;
|
||||||
);
|
|
||||||
ui.add_space(8.0);
|
let subtitle_req = ui
|
||||||
let can_generate = self.pending_source.is_some() && self.pending_output.is_some();
|
.allocate_ui_with_layout(
|
||||||
self.execution_panel.ui(ui, can_generate);
|
egui::Vec2::new(col_w, 0.0),
|
||||||
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|
|ui| self.add_subtitle_form.ui(ui),
|
||||||
|
)
|
||||||
|
.inner;
|
||||||
|
|
||||||
|
(audio_req, subtitle_req)
|
||||||
|
} else {
|
||||||
|
let ar = self.add_audio_form.ui(ui);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
let sr = self.add_subtitle_form.ui(ui);
|
||||||
|
(ar, sr)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(req) = audio_req {
|
||||||
|
let _ =
|
||||||
|
AddAudioTrack::execute(project, req.path, req.offset, req.language);
|
||||||
}
|
}
|
||||||
|
if let Some(req) = subtitle_req {
|
||||||
|
let _ =
|
||||||
|
AddSubtitle::execute(project, req.path, req.offset, req.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// ── Faixas externas adicionadas ───────────────────────
|
||||||
|
let remove_events = self.added_track_list.ui(ui, &project.tracks);
|
||||||
|
for event in remove_events {
|
||||||
|
use crate::ui::components::added_track_list::AddedTrackEvent;
|
||||||
|
match event {
|
||||||
|
AddedTrackEvent::RemoveRequested(id) => {
|
||||||
|
let _ = RemoveTrack::execute(project, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use crate::domain::entities::Track;
|
||||||
|
use crate::domain::value_objects::TrackId;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
/// Evento emitido por interações com a lista de faixas externas adicionadas.
|
||||||
|
pub enum AddedTrackEvent {
|
||||||
|
RemoveRequested(TrackId),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista as faixas externas adicionadas pelo usuário e permite removê-las.
|
||||||
|
pub struct AddedTrackList;
|
||||||
|
|
||||||
|
impl AddedTrackList {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
AddedTrackList
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderiza a lista. Retorna eventos de remoção.
|
||||||
|
pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
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(4)
|
||||||
|
.striped(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.strong("Tipo");
|
||||||
|
ui.strong("Arquivo");
|
||||||
|
ui.strong("Idioma");
|
||||||
|
ui.strong("");
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
for track in tracks {
|
||||||
|
let (kind_label, file_name, language) = match track {
|
||||||
|
Track::Audio(t) => (
|
||||||
|
"Áudio",
|
||||||
|
t.path
|
||||||
|
.as_path()
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("—")
|
||||||
|
.to_string(),
|
||||||
|
t.language.as_str().to_string(),
|
||||||
|
),
|
||||||
|
Track::Subtitle(t) => (
|
||||||
|
"Legenda",
|
||||||
|
t.path
|
||||||
|
.as_path()
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("—")
|
||||||
|
.to_string(),
|
||||||
|
t.language.as_str().to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.label(kind_label);
|
||||||
|
ui.label(&file_name).on_hover_text(track_full_path(track));
|
||||||
|
ui.label(language);
|
||||||
|
|
||||||
|
if ui.small_button("🗑 Remover").clicked() {
|
||||||
|
events.push(AddedTrackEvent::RemoveRequested(track.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_full_path(track: &Track) -> String {
|
||||||
|
match track {
|
||||||
|
Track::Audio(t) => t.path.to_string_lossy().into_owned(),
|
||||||
|
Track::Subtitle(t) => t.path.to_string_lossy().into_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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 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