748 lines
34 KiB
Rust
748 lines
34 KiB
Rust
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
|
|
use crate::adapters::filesystem::file_picker::FilePickerAdapter;
|
|
use crate::application::use_cases::{
|
|
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
|
|
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
|
|
load_media_info::LoadMediaInfo, remove_track::RemoveTrack,
|
|
};
|
|
use crate::domain::entities::{MkvOutput, Project, VideoFile};
|
|
use crate::domain::value_objects::FilePath;
|
|
use crate::infrastructure::process::run_ffmpeg_async;
|
|
use crate::ui::components::{
|
|
add_audio_track_form::AddAudioTrackForm,
|
|
add_subtitle_form::AddSubtitleForm,
|
|
added_track_list::AddedTrackList,
|
|
batch_panel::{BatchItem, BatchPanel, BatchPanelEvent},
|
|
execution_panel::{ExecutionPanel, ExecutionState},
|
|
existing_track_list::ExistingTrackList,
|
|
output_selector::OutputSelector,
|
|
video_selector::VideoSelector,
|
|
};
|
|
use eframe::egui;
|
|
use std::sync::mpsc;
|
|
|
|
/// Aba ativa da interface.
|
|
#[derive(PartialEq)]
|
|
enum ActiveTab {
|
|
Single,
|
|
Batch,
|
|
}
|
|
|
|
/// Mensagens enviadas do background thread para a UI.
|
|
enum BackgroundMsg {
|
|
LogLine(String),
|
|
Done,
|
|
Error(String),
|
|
}
|
|
|
|
/// Estado da aplicação — contém o `Project` como única fonte de verdade.
|
|
pub struct App {
|
|
project: Option<Project>,
|
|
pending_source: Option<FilePath>,
|
|
pending_output: Option<FilePath>,
|
|
|
|
// Componentes de UI com estado próprio
|
|
video_selector: VideoSelector,
|
|
output_selector: OutputSelector,
|
|
existing_track_list: ExistingTrackList,
|
|
added_track_list: AddedTrackList,
|
|
add_audio_form: AddAudioTrackForm,
|
|
add_subtitle_form: AddSubtitleForm,
|
|
execution_panel: ExecutionPanel,
|
|
|
|
// Canal de comunicação do background thread
|
|
bg_rx: Option<mpsc::Receiver<BackgroundMsg>>,
|
|
|
|
// Sender para cancelar a geração em andamento
|
|
cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
|
|
|
// Mensagem de erro global (ex: dependências ausentes)
|
|
global_error: Option<String>,
|
|
|
|
// Mensagem de feedback de persistência (sucesso ou erro)
|
|
session_msg: Option<(String, bool)>, // (texto, é_erro)
|
|
|
|
// Aba ativa (Projeto Único ou Lote)
|
|
active_tab: ActiveTab,
|
|
// Itens do carrinho de lote
|
|
batch_items: Vec<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 {
|
|
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
|
// Verifica dependências na inicialização
|
|
let global_error = crate::infrastructure::process::validate_dependencies()
|
|
.err()
|
|
.map(|e| e.to_string());
|
|
|
|
// Notifica o usuário se existe sessão salva ao iniciar
|
|
let session_msg = if crate::infrastructure::persistence::session_exists() {
|
|
Some((
|
|
"Sessão anterior encontrada. Clique em \"Carregar sessão\" para restaurar."
|
|
.to_string(),
|
|
false,
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
App {
|
|
project: None,
|
|
pending_source: None,
|
|
pending_output: None,
|
|
video_selector: VideoSelector::new(),
|
|
output_selector: OutputSelector::new(),
|
|
existing_track_list: ExistingTrackList::new(),
|
|
added_track_list: AddedTrackList::new(),
|
|
add_audio_form: AddAudioTrackForm::new(),
|
|
add_subtitle_form: AddSubtitleForm::new(),
|
|
execution_panel: ExecutionPanel::new(),
|
|
bg_rx: None,
|
|
cancel_tx: None,
|
|
global_error,
|
|
session_msg,
|
|
active_tab: ActiveTab::Single,
|
|
batch_items: Vec::new(),
|
|
batch_panel: BatchPanel::new(),
|
|
batch_processing_index: None,
|
|
}
|
|
}
|
|
|
|
/// Tenta construir ou atualizar o Project quando source e output estão disponíveis.
|
|
fn try_build_project(&mut self) {
|
|
let source = match &self.pending_source {
|
|
Some(p) => p.clone(),
|
|
None => return,
|
|
};
|
|
let output = match &self.pending_output {
|
|
Some(p) => p.clone(),
|
|
None => return,
|
|
};
|
|
match Project::new(VideoFile::new(source.clone()), MkvOutput::new(output)) {
|
|
Ok(mut project) => {
|
|
// Carrega faixas existentes via ffprobe
|
|
let probe = FfprobeGateway;
|
|
match LoadMediaInfo::execute(&mut project, &probe) {
|
|
Ok(_) => {
|
|
self.existing_track_list
|
|
.sync_tracks(&project.existing_tracks);
|
|
}
|
|
Err(e) => {
|
|
self.execution_panel.state = ExecutionState::Error(e.to_string());
|
|
}
|
|
}
|
|
self.project = Some(project);
|
|
}
|
|
Err(e) => {
|
|
self.execution_panel.state = ExecutionState::Error(e.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Processa mensagens pendentes do background thread.
|
|
/// Em modo lote, atualiza o item em execução e avança para o próximo automaticamente.
|
|
fn poll_background(&mut self, ctx: &egui::Context) {
|
|
// Resultado do término: Ok ou Err(mensagem)
|
|
enum DoneResult {
|
|
Ok,
|
|
Err(String),
|
|
}
|
|
|
|
let mut done_result: Option<DoneResult> = None;
|
|
|
|
if let Some(rx) = &self.bg_rx {
|
|
loop {
|
|
match rx.try_recv() {
|
|
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);
|
|
}
|
|
}
|
|
Ok(BackgroundMsg::Done) => {
|
|
done_result = Some(DoneResult::Ok);
|
|
break;
|
|
}
|
|
Ok(BackgroundMsg::Error(e)) => {
|
|
done_result = Some(DoneResult::Err(e));
|
|
break;
|
|
}
|
|
Err(mpsc::TryRecvError::Empty) => break,
|
|
Err(mpsc::TryRecvError::Disconnected) => {
|
|
done_result = Some(DoneResult::Ok);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(result) = done_result {
|
|
self.bg_rx = None;
|
|
|
|
if let Some(idx) = self.batch_processing_index.take() {
|
|
// Modo lote: atualiza estado do item concluído
|
|
match result {
|
|
DoneResult::Ok => {
|
|
self.batch_items[idx].state = ExecutionState::Success;
|
|
}
|
|
DoneResult::Err(e) => {
|
|
self.batch_items[idx].state = ExecutionState::Error(e);
|
|
}
|
|
}
|
|
// Avança automaticamente para o próximo item aguardando
|
|
let next = self
|
|
.batch_items
|
|
.iter()
|
|
.position(|item| matches!(item.state, ExecutionState::Idle));
|
|
if let Some(next_idx) = next {
|
|
self.start_batch_item(next_idx, ctx.clone());
|
|
}
|
|
} else {
|
|
// Modo único
|
|
match result {
|
|
DoneResult::Ok => {
|
|
self.execution_panel.state = ExecutionState::Success;
|
|
}
|
|
DoneResult::Err(e) => {
|
|
self.execution_panel.state = ExecutionState::Error(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inicia geração do MKV em background thread com progresso em tempo real.
|
|
fn start_generation(&mut self, ctx: egui::Context) {
|
|
let project = match &self.project {
|
|
Some(p) => p.clone(),
|
|
None => return,
|
|
};
|
|
|
|
let args = FfmpegCommandBuilder::build(&project);
|
|
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
|
|
self.bg_rx = Some(rx);
|
|
self.execution_panel.state = ExecutionState::Running;
|
|
self.execution_panel.log_lines.clear();
|
|
|
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
|
|
self.cancel_tx = Some(cancel_tx);
|
|
|
|
std::thread::spawn(move || {
|
|
// Canal std para receber linhas de log do run_ffmpeg_async
|
|
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, cancel_rx));
|
|
|
|
// 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(_) => {
|
|
let _ = tx.send(BackgroundMsg::Done);
|
|
}
|
|
Err(e) => {
|
|
let _ = tx.send(BackgroundMsg::Error(e.to_string()));
|
|
}
|
|
}
|
|
ctx.request_repaint();
|
|
});
|
|
}
|
|
|
|
/// Cancela a geração em andamento (modo projeto único), encerrando o processo FFmpeg.
|
|
fn cancel_generation(&mut self) {
|
|
if let Some(tx) = self.cancel_tx.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
self.bg_rx = None;
|
|
self.execution_panel.state = ExecutionState::Cancelled;
|
|
}
|
|
|
|
/// Inicia o processamento de um item específico do carrinho de lote.
|
|
fn start_batch_item(&mut self, index: usize, ctx: egui::Context) {
|
|
let project = self.batch_items[index].project.clone();
|
|
self.batch_items[index].state = ExecutionState::Running;
|
|
self.batch_items[index].log_lines.clear();
|
|
self.batch_processing_index = Some(index);
|
|
|
|
let args = FfmpegCommandBuilder::build(&project);
|
|
let (tx, rx) = mpsc::channel::<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 {
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
// Consome mensagens assíncronas
|
|
self.poll_background(ctx);
|
|
if self.bg_rx.is_some() {
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
|
}
|
|
|
|
// ── Cabeçalho (fixo no topo) ──────────────────────────────────────────
|
|
let header_height =
|
|
42.0 + if self.global_error.is_some() {
|
|
22.0
|
|
} else {
|
|
0.0
|
|
} + if self.session_msg.is_some() {
|
|
22.0
|
|
} else {
|
|
0.0
|
|
};
|
|
egui::TopBottomPanel::top("header_panel")
|
|
.exact_height(header_height)
|
|
.show(ctx, |ui| {
|
|
ui.add_space(4.0);
|
|
ui.horizontal(|ui| {
|
|
ui.heading("Editor de Faixas de Mídia");
|
|
|
|
ui.with_layout(
|
|
egui::Layout::right_to_left(egui::Align::Center),
|
|
|ui| {
|
|
// Botão "Salvar sessão"
|
|
let can_save =
|
|
self.project.is_some() || !self.batch_items.is_empty();
|
|
if ui
|
|
.add_enabled(
|
|
can_save,
|
|
egui::Button::new("💾 Salvar sessão"),
|
|
)
|
|
.on_disabled_hover_text(
|
|
"Abra um projeto ou adicione itens ao lote para salvar a sessão.",
|
|
)
|
|
.clicked()
|
|
{
|
|
use crate::infrastructure::persistence::SessionData;
|
|
let data = SessionData {
|
|
single_project: self.project.clone(),
|
|
batch_projects: self
|
|
.batch_items
|
|
.iter()
|
|
.map(|item| item.project.clone())
|
|
.collect(),
|
|
};
|
|
match crate::infrastructure::persistence::save_session(&data) {
|
|
Ok(_) => {
|
|
self.session_msg = Some((
|
|
"Sessão salva com sucesso.".to_string(),
|
|
false,
|
|
));
|
|
}
|
|
Err(e) => {
|
|
self.session_msg =
|
|
Some((format!("Erro ao salvar: {}", e), true));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Botão "Carregar sessão"
|
|
let can_load =
|
|
crate::infrastructure::persistence::session_exists();
|
|
if ui
|
|
.add_enabled(
|
|
can_load,
|
|
egui::Button::new("📂 Carregar sessão"),
|
|
)
|
|
.on_disabled_hover_text("Nenhuma sessão salva encontrada.")
|
|
.clicked()
|
|
{
|
|
match crate::infrastructure::persistence::load_session() {
|
|
Ok(data) => {
|
|
let probe = FfprobeGateway;
|
|
// Restaura projeto único
|
|
if let Some(mut project) = data.single_project {
|
|
// Re-executa ffprobe para garantir que existing_tracks
|
|
// está atualizado independentemente do que foi salvo.
|
|
let _ = LoadMediaInfo::execute(&mut project, &probe);
|
|
self.pending_source =
|
|
Some(project.source.path.clone());
|
|
self.pending_output =
|
|
Some(project.output.path.clone());
|
|
self.existing_track_list
|
|
.sync_tracks(&project.existing_tracks);
|
|
self.project = Some(project);
|
|
}
|
|
// Restaura itens do lote (estado resetado para Idle)
|
|
// Re-executa ffprobe em cada projeto para popular
|
|
// existing_tracks que podem estar vazios na sessão salva.
|
|
if !data.batch_projects.is_empty() {
|
|
use crate::ui::components::batch_panel::BatchItem;
|
|
self.batch_items = data
|
|
.batch_projects
|
|
.into_iter()
|
|
.map(|mut project| {
|
|
let _ = LoadMediaInfo::execute(
|
|
&mut project,
|
|
&probe,
|
|
);
|
|
BatchItem::new(project)
|
|
})
|
|
.collect();
|
|
}
|
|
let single_ok = self.project.is_some();
|
|
let batch_count = self.batch_items.len();
|
|
self.session_msg = Some((
|
|
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,
|
|
));
|
|
}
|
|
Err(e) => {
|
|
self.session_msg = Some((
|
|
format!("Erro ao carregar sessão: {}", e),
|
|
true,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
if let Some(ref err) = self.global_error {
|
|
ui.colored_label(
|
|
egui::Color32::RED,
|
|
format!("⚠ Dependência ausente: {}", err),
|
|
);
|
|
}
|
|
|
|
if let Some((msg, is_err)) = self.session_msg.clone() {
|
|
let color = if is_err {
|
|
egui::Color32::RED
|
|
} else {
|
|
egui::Color32::from_rgb(0, 180, 80)
|
|
};
|
|
ui.horizontal(|ui| {
|
|
ui.colored_label(color, &msg);
|
|
if ui.small_button("✕").clicked() {
|
|
self.session_msg = None;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── 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")
|
|
.resizable(false)
|
|
.show(ctx, |ui| {
|
|
ui.add_space(4.0);
|
|
let can_generate =
|
|
self.pending_source.is_some() && self.pending_output.is_some();
|
|
let (generate, cancel) = self.execution_panel.ui(ui, can_generate);
|
|
if generate {
|
|
let ctx_clone = ctx.clone();
|
|
self.start_generation(ctx_clone);
|
|
}
|
|
if cancel {
|
|
self.cancel_generation();
|
|
}
|
|
ui.add_space(4.0);
|
|
});
|
|
}
|
|
|
|
// ── Barra lateral esquerda (apenas modo Projeto Único) ─────────────────
|
|
if self.active_tab == ActiveTab::Single {
|
|
egui::SidePanel::left("sidebar_panel")
|
|
.min_width(220.0)
|
|
.max_width(400.0)
|
|
.resizable(true)
|
|
.show(ctx, |ui| {
|
|
egui::ScrollArea::vertical()
|
|
.auto_shrink([false, false])
|
|
.show(ui, |ui| {
|
|
ui.add_space(8.0);
|
|
|
|
// ── Seleção de vídeo ──────────────────────────────────
|
|
if let Some(path) = self.video_selector.ui(ui) {
|
|
self.pending_source = Some(path);
|
|
if self.pending_output.is_some() {
|
|
self.try_build_project();
|
|
}
|
|
}
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// ── Seleção de saída ──────────────────────────────────
|
|
// Passa o stem do vídeo para pré-preencher o nome sugerido
|
|
let source_stem: Option<&str> = self
|
|
.pending_source
|
|
.as_ref()
|
|
.and_then(|p| p.as_path().file_stem())
|
|
.and_then(|s| s.to_str());
|
|
if let Some(path) = self.output_selector.ui(ui, source_stem) {
|
|
self.pending_output = Some(path);
|
|
if self.pending_source.is_some() {
|
|
self.try_build_project();
|
|
}
|
|
}
|
|
|
|
if self.project.is_none() {
|
|
ui.add_space(16.0);
|
|
ui.separator();
|
|
ui.add_space(8.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Selecione o arquivo de vídeo e o destino \
|
|
de saída para começar.",
|
|
)
|
|
.italics()
|
|
.weak(),
|
|
);
|
|
}
|
|
|
|
ui.add_space(8.0);
|
|
});
|
|
});
|
|
} // fim da barra lateral (modo Projeto Único)
|
|
|
|
// ── Painel central: faixas / formulários ou painel de lote ───────────
|
|
egui::CentralPanel::default().show(ctx, |ui| {
|
|
// Modo Lote
|
|
if self.active_tab == ActiveTab::Batch {
|
|
egui::ScrollArea::vertical()
|
|
.auto_shrink([false, false])
|
|
.show(ui, |ui| {
|
|
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(mut project) => {
|
|
let probe = FfprobeGateway;
|
|
let _ = LoadMediaInfo::execute(&mut project, &probe);
|
|
self.batch_items.push(BatchItem::new(project));
|
|
}
|
|
BatchPanelEvent::EditItem(idx, mut project) => {
|
|
let probe = FfprobeGateway;
|
|
let _ = LoadMediaInfo::execute(&mut project, &probe);
|
|
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()
|
|
.auto_shrink([false, false])
|
|
.show(ui, |ui| {
|
|
if let Some(project) = &mut self.project {
|
|
// ── Faixas existentes ─────────────────────────────────
|
|
let events = self.existing_track_list.ui(ui, &project.existing_tracks);
|
|
for event in events {
|
|
use crate::domain::entities::TrackKind;
|
|
use crate::ui::components::existing_track_list::ExistingTrackEvent;
|
|
match event {
|
|
ExistingTrackEvent::OffsetChanged(id, offset) => {
|
|
let _ = EditExistingTrackSync::execute(project, id, offset);
|
|
}
|
|
ExistingTrackEvent::ExportRequested(id) => {
|
|
if let Some(track) =
|
|
project.existing_tracks.iter().find(|t| t.id == id).cloned()
|
|
{
|
|
// Stem do arquivo de vídeo base para nomear o arquivo exportado
|
|
let base_stem = project
|
|
.source
|
|
.path
|
|
.as_path()
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("track");
|
|
let maybe_output = match track.kind {
|
|
TrackKind::Audio => FilePickerAdapter::save_audio(
|
|
base_stem,
|
|
&track.codec,
|
|
),
|
|
TrackKind::Subtitle => {
|
|
FilePickerAdapter::save_subtitle(
|
|
base_stem,
|
|
&track.codec,
|
|
)
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(output_path) = maybe_output {
|
|
let gateway = FfmpegGateway;
|
|
match ExportTrack::execute(
|
|
&project.source.path,
|
|
&track,
|
|
&output_path,
|
|
&gateway,
|
|
) {
|
|
Ok(_) => {
|
|
self.execution_panel.state =
|
|
ExecutionState::Success;
|
|
self.execution_panel.add_log(format!(
|
|
"Faixa exportada para: {}",
|
|
output_path.to_string_lossy()
|
|
));
|
|
}
|
|
Err(e) => {
|
|
self.execution_panel.state =
|
|
ExecutionState::Error(e.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// ── Formulários: lado a lado se largura >= 600 px ─────
|
|
// Executa o render fora do bloco de borrow de `project`
|
|
// e coleta as requisições para aplicar em seguida.
|
|
let wide = ui.available_width() >= 600.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;
|
|
|
|
let audio_req = ui
|
|
.allocate_ui_with_layout(
|
|
egui::Vec2::new(col_w, 0.0),
|
|
egui::Layout::top_down(egui::Align::Min),
|
|
|ui| self.add_audio_form.ui(ui),
|
|
)
|
|
.inner;
|
|
|
|
let subtitle_req = ui
|
|
.allocate_ui_with_layout(
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|