Compare commits

..
2 Commits
15 changed files with 308 additions and 18 deletions
+15
View File
@@ -82,6 +82,20 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
- O usuário pode atribuir um código de idioma a cada faixa (ex: `por`, `eng`) - O usuário pode atribuir um código de idioma a cada faixa (ex: `por`, `eng`)
- Implementado via `ffmpeg -metadata:s` - Implementado via `ffmpeg -metadata:s`
### RF-10 — Definir nome da faixa
- O usuário pode preencher um título livre para cada faixa externa adicionada (ex: `Português`, `Comentários`)
- O campo aceita string vazia, que instrui o `FfmpegCommandBuilder` a emitir `title=` limpando qualquer título herdado do arquivo fonte
- Objetivo principal: sobrescrever títulos gerados automaticamente por ferramentas externas (ex: `"ISO Media file produced by Google Inc."` em arquivos M4A do Google) que confundem players como Jellyfin mobile
- Implementado via `ffmpeg -metadata:s:a:{idx} title=` ou `-metadata:s:s:{idx} title=`; sempre emitido, mesmo quando vazio
### RF-11 — Definir faixa padrão
- O usuário pode marcar uma faixa externa como “faixa padrão” do container MKV
- Quando marcada, o `FfmpegCommandBuilder` emite `-disposition:a/s:{idx} default` para a faixa selecionada e `-disposition:a/s:{orig_idx} 0` para todas as faixas existentes do mesmo tipo, removendo o flag `default=1` que o arquivo fonte carrega
- Objetivo: garantir que players como Jellyfin selecionem automaticamente a faixa preferida sem depender de configuração manual no side do player
- Implementado via `ffmpeg -disposition:a:{idx} default`
### RF-05 — Selecionar arquivo de vídeo base ### RF-05 — Selecionar arquivo de vídeo base
- O usuário seleciona o arquivo de vídeo de entrada - O usuário seleciona o arquivo de vídeo de entrada
@@ -319,6 +333,7 @@ src/
| DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | Usar MKV como container padrão | | DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | Usar MKV como container padrão |
| DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido | | DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido |
| DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | Validar presença de ffprobe na inicialização; exibir mensagem clara se ausente | | DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | Validar presença de ffprobe na inicialização; exibir mensagem clara se ausente |
| DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando; as RFC RF-10 e RF-11 mitigam parcialmente — `title` e `disposition` são emitidos corretamente, porém o Jellyfin mobile pode ter comportamento diferente do web; requer testes com arquivo gerado |
--- ---
+34
View File
@@ -112,6 +112,39 @@ src/
--- ---
## Inventário de Mudanças — Compatibilidade com Jellyfin (01/03/2026)
### Contexto
Arquivos M4A produzidos pelo Google (ex: downloads do YouTube Music ou Google Drive) carregam
um metadado `title` na stream de áudio com o valor `"ISO Media file produced by Google Inc."`. Quando
o FFmpeg faz o mux sem sobrescrever esse metadado, ele é copiado literalmente para a faixa do MKV.
O Jellyfin **web** é tolerante e usa a tag `language` para selecionar a faixa preferida. Já o
Jellyfin **mobile** usa o campo `title` da stream para exibir e selecionar faixas — quando não
reconhece o título como idioma, não realiza seleção automática e mantém a faixa original.
Algo semelhante ocorre com `disposition:default`: a faixa original do fonte fica com `default=1`;
a nova faixa fica com `default=0`; o Jellyfin mobile respeitam esse flag e tocam a original.
> **Status:** mudanças implementadas e compilando; investigação em andamento — o problema no
> Jellyfin mobile ainda não foi completamente resolvido.
### Mudanças por camada
| Arquivo | Mudança |
| --- | --- |
| `domain/entities/audio_track.rs` | Adicionado `is_default: bool` e `title: String` a `AudioTrack` |
| `domain/entities/subtitle_track.rs` | Adicionado `is_default: bool` e `title: String` a `SubtitleTrack` |
| `application/use_cases/add_audio_track.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
| `application/use_cases/add_subtitle.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
| `adapters/ffmpeg/command_builder.rs` | Emite `-disposition:a/s:{idx} 0` nas faixas existentes quando uma externa é marcada como padrão; emite `-disposition:a/s:{idx} default` na faixa marcada; emite `-metadata:s:a/s:{idx} title={valor}` para toda faixa externa (string vazia apaga o título herdado) |
| `ui/components/add_audio_track_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
| `ui/components/add_subtitle_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
| `ui/components/batch_panel.rs` | `PendingAudio` e `PendingSubtitle` propagam `is_default` e `title` |
---
## Testes Unitários (33/33 passando) ## Testes Unitários (33/33 passando)
### Domain — Value Objects ### Domain — Value Objects
@@ -200,6 +233,7 @@ rfd = "0.14"
- **`Project` rejeita `output == source`** — validado no construtor - **`Project` rejeita `output == source`** — validado no construtor
- **Termos FFmpeg nunca aparecem na UI** — a interface usa linguagem do usuário final - **Termos FFmpeg nunca aparecem na UI** — a interface usa linguagem do usuário final
- **Exceção documentada ao RNF-01** — `build_export()` omite `-c copy` exclusivamente para legendas (conversão de container de texto, sem processamento de mídia); comentário inline explica a exceção - **Exceção documentada ao RNF-01** — `build_export()` omite `-c copy` exclusivamente para legendas (conversão de container de texto, sem processamento de mídia); comentário inline explica a exceção
- **`title=` sempre emitido** — `FfmpegCommandBuilder` emite `-metadata:s:a/s:{idx} title=` para toda faixa externa, garantindo que títulos herdados do arquivo fonte sejam sobrescritos; string vazia limpa o campo no MKV
--- ---
+98
View File
@@ -91,6 +91,18 @@ impl FfmpegCommandBuilder {
ext_idx += 1; ext_idx += 1;
} }
// ── Opções de muxing ─────────────────────────────────────────────────────
// -max_interleave_delta 0: remove o limite do buffer de intercalação (padrão: 10s).
// Sem isso, quando o áudio externo fica à frente do vídeo, o FFmpeg descarta
// pacotes silenciosamente, gerando trechos mudos no output. Com 0, nenhum
// pacote é descartado por excesso de delta — apenas memória RAM é consumida.
// -avoid_negative_ts make_zero: normaliza timestamps negativos comuns em M4A/AAC
// de serviços de streaming (DASH), que causam dessincronização no muxer MKV.
args.push("-max_interleave_delta".to_string());
args.push("0".to_string());
args.push("-avoid_negative_ts".to_string());
args.push("make_zero".to_string());
// ── -c copy (SEMPRE — invariante central do produto) ──────────────────── // ── -c copy (SEMPRE — invariante central do produto) ────────────────────
args.push("-c".to_string()); args.push("-c".to_string());
args.push("copy".to_string()); args.push("copy".to_string());
@@ -115,16 +127,96 @@ impl FfmpegCommandBuilder {
Track::Audio(t) => { Track::Audio(t) => {
args.push(format!("-metadata:s:a:{}", ext_audio_idx)); args.push(format!("-metadata:s:a:{}", ext_audio_idx));
args.push(format!("language={}", t.language)); args.push(format!("language={}", t.language));
// Sempre emite title (mesmo vazio) para sobrescrever qualquer título
// herdado do arquivo fonte (ex: "ISO Media file produced by Google Inc.").
// Players como Jellyfin mobile usam o title para identificar a faixa.
args.push(format!("-metadata:s:a:{}", ext_audio_idx));
args.push(format!("title={}", t.title));
ext_audio_idx += 1; ext_audio_idx += 1;
} }
Track::Subtitle(t) => { Track::Subtitle(t) => {
args.push(format!("-metadata:s:s:{}", ext_sub_idx)); args.push(format!("-metadata:s:s:{}", ext_sub_idx));
args.push(format!("language={}", t.language)); args.push(format!("language={}", t.language));
args.push(format!("-metadata:s:s:{}", ext_sub_idx));
args.push(format!("title={}", t.title));
ext_sub_idx += 1; ext_sub_idx += 1;
} }
} }
} }
// ── Disposições (faixa padrão) ────────────────────────────────────────
// Quando o usuário marca uma faixa externa como "padrão", é necessário:
// 1. Remover o flag `default` de todas as faixas existentes do mesmo tipo
// (o arquivo fonte geralmente já tem `disposition:default=1` na sua
// primeira faixa de áudio, e Jellyfin/Kodi respeitam esse flag).
// 2. Emitir `-disposition:a/s:{idx} default` para a faixa externa marcada.
//
// Índices de disposição seguem a ordem de streams do tipo no output:
// - Faixas existentes (existing_tracks) vêm primeiro, na ordem do map.
// - Faixas externas (tracks) vêm depois, na ordem de adição.
let existing_audio_count_disp = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Audio))
.count();
let existing_sub_count_disp = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Subtitle))
.count();
let any_ext_audio_default = project
.tracks
.iter()
.any(|t| matches!(t, Track::Audio(a) if a.is_default));
let any_ext_sub_default = project
.tracks
.iter()
.any(|t| matches!(t, Track::Subtitle(s) if s.is_default));
// Remove default das faixas existentes quando uma faixa externa é padrão
if any_ext_audio_default {
for i in 0..existing_audio_count_disp {
args.push(format!("-disposition:a:{}", i));
args.push("0".to_string());
}
}
if any_ext_sub_default {
for i in 0..existing_sub_count_disp {
args.push(format!("-disposition:s:{}", i));
args.push("0".to_string());
}
}
// Emite `default` para as faixas externas marcadas
let mut disp_audio_idx = 0usize;
let mut disp_sub_idx = 0usize;
for track in &project.tracks {
match track {
Track::Audio(a) => {
if a.is_default {
args.push(format!(
"-disposition:a:{}",
existing_audio_count_disp + disp_audio_idx
));
args.push("default".to_string());
}
disp_audio_idx += 1;
}
Track::Subtitle(s) => {
if s.is_default {
args.push(format!(
"-disposition:s:{}",
existing_sub_count_disp + disp_sub_idx
));
args.push("default".to_string());
}
disp_sub_idx += 1;
}
}
}
// ── Output ────────────────────────────────────────────────────────────── // ── Output ──────────────────────────────────────────────────────────────
args.push(project.output.path.to_string_lossy().to_string()); args.push(project.output.path.to_string_lossy().to_string());
@@ -216,6 +308,8 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::from_ms(1200), SyncOffset::from_ms(1200),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
) )
.unwrap(); .unwrap();
@@ -233,6 +327,8 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::default(), SyncOffset::default(),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
) )
.unwrap(); .unwrap();
@@ -252,6 +348,8 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::default(), SyncOffset::default(),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
) )
.unwrap(); .unwrap();
+6 -2
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use crate::domain::entities::{AudioTrack, Project, Track}; use crate::domain::entities::{AudioTrack, Project, Track};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use anyhow::Result;
/// Adiciona uma faixa de áudio externa ao projeto. /// Adiciona uma faixa de áudio externa ao projeto.
pub struct AddAudioTrack; pub struct AddAudioTrack;
@@ -11,9 +11,11 @@ impl AddAudioTrack {
path: FilePath, path: FilePath,
offset: SyncOffset, offset: SyncOffset,
language: TrackLanguage, language: TrackLanguage,
is_default: bool,
title: String,
) -> Result<()> { ) -> Result<()> {
let id = project.next_track_id(); let id = project.next_track_id();
let track = AudioTrack::new(id, path, offset, language); let track = AudioTrack::new(id, path, offset, language, is_default, title);
project.tracks.push(Track::Audio(track)); project.tracks.push(Track::Audio(track));
Ok(()) Ok(())
} }
@@ -39,6 +41,8 @@ mod tests {
FilePath::from("audio_pt.aac"), FilePath::from("audio_pt.aac"),
SyncOffset::default(), SyncOffset::default(),
lang, lang,
false,
String::new(),
) )
.unwrap(); .unwrap();
+6 -2
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use crate::domain::entities::{Project, SubtitleTrack, Track}; use crate::domain::entities::{Project, SubtitleTrack, Track};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use anyhow::Result;
/// Adiciona uma faixa de legenda externa ao projeto. /// Adiciona uma faixa de legenda externa ao projeto.
pub struct AddSubtitle; pub struct AddSubtitle;
@@ -11,9 +11,11 @@ impl AddSubtitle {
path: FilePath, path: FilePath,
offset: SyncOffset, offset: SyncOffset,
language: TrackLanguage, language: TrackLanguage,
is_default: bool,
title: String,
) -> Result<()> { ) -> Result<()> {
let id = project.next_track_id(); let id = project.next_track_id();
let track = SubtitleTrack::new(id, path, offset, language); let track = SubtitleTrack::new(id, path, offset, language, is_default, title);
project.tracks.push(Track::Subtitle(track)); project.tracks.push(Track::Subtitle(track));
Ok(()) Ok(())
} }
@@ -38,6 +40,8 @@ mod tests {
FilePath::from("sub_en.srt"), FilePath::from("sub_en.srt"),
SyncOffset::default(), SyncOffset::default(),
lang, lang,
false,
String::new(),
) )
.unwrap(); .unwrap();
+2
View File
@@ -37,6 +37,8 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::default(), SyncOffset::default(),
lang, lang,
false,
String::new(),
) )
.unwrap(); .unwrap();
let id = project.tracks[0].id(); let id = project.tracks[0].id();
@@ -49,6 +49,8 @@ mod tests {
FilePath::from("audio_pt.aac"), FilePath::from("audio_pt.aac"),
SyncOffset::from_ms(1200), SyncOffset::from_ms(1200),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
) )
.unwrap(); .unwrap();
@@ -38,6 +38,8 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::from_ms(0), SyncOffset::from_ms(0),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
))); )));
assert_eq!(project.tracks.len(), 1); assert_eq!(project.tracks.len(), 1);
@@ -36,6 +36,8 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::default(), SyncOffset::default(),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
) )
.unwrap(); .unwrap();
let id = project.tracks[0].id(); let id = project.tracks[0].id();
+24 -3
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
use serde::{Deserialize, Serialize};
/// Faixa de áudio externa adicionada pelo usuário. /// Faixa de áudio externa adicionada pelo usuário.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -8,10 +8,31 @@ pub struct AudioTrack {
pub path: FilePath, pub path: FilePath,
pub offset: SyncOffset, pub offset: SyncOffset,
pub language: TrackLanguage, pub language: TrackLanguage,
/// Quando verdadeiro, emite `-disposition:a:{idx} default` no FFmpeg,
/// tornando esta faixa a padrão para players como Jellyfin.
pub is_default: bool,
/// Título exibido na lista de faixas do player (ex: "Português", "Comentários").
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte
/// (ex: "ISO Media file produced by Google Inc.").
pub title: String,
} }
impl AudioTrack { impl AudioTrack {
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self { pub fn new(
AudioTrack { id, path, offset, language } id: TrackId,
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
) -> Self {
AudioTrack {
id,
path,
offset,
language,
is_default,
title,
}
} }
} }
+22 -3
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
use serde::{Deserialize, Serialize};
/// Faixa de legenda externa adicionada pelo usuário. /// Faixa de legenda externa adicionada pelo usuário.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -8,10 +8,29 @@ pub struct SubtitleTrack {
pub path: FilePath, pub path: FilePath,
pub offset: SyncOffset, pub offset: SyncOffset,
pub language: TrackLanguage, pub language: TrackLanguage,
/// Quando verdadeiro, emite `-disposition:s:{idx} default` no FFmpeg.
pub is_default: bool,
/// Título exibido na lista de faixas do player (ex: "Português", "Forçada").
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte.
pub title: String,
} }
impl SubtitleTrack { impl SubtitleTrack {
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self { pub fn new(
SubtitleTrack { id, path, offset, language } id: TrackId,
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
) -> Self {
SubtitleTrack {
id,
path,
offset,
language,
is_default,
title,
}
} }
} }
+16 -4
View File
@@ -718,12 +718,24 @@ impl eframe::App for App {
}; };
if let Some(req) = audio_req { if let Some(req) = audio_req {
let _ = let _ = AddAudioTrack::execute(
AddAudioTrack::execute(project, req.path, req.offset, req.language); project,
req.path,
req.offset,
req.language,
req.is_default,
req.title,
);
} }
if let Some(req) = subtitle_req { if let Some(req) = subtitle_req {
let _ = let _ = AddSubtitle::execute(
AddSubtitle::execute(project, req.path, req.offset, req.language); project,
req.path,
req.offset,
req.language,
req.is_default,
req.title,
);
} }
ui.add_space(8.0); ui.add_space(8.0);
+32 -2
View File
@@ -1,13 +1,15 @@
use eframe::egui;
use crate::adapters::filesystem::FilePickerAdapter; use crate::adapters::filesystem::FilePickerAdapter;
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField}; use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui;
/// Evento emitido ao confirmar adição de faixa de áudio. /// Evento emitido ao confirmar adição de faixa de áudio.
pub struct AddAudioTrackRequest { pub struct AddAudioTrackRequest {
pub path: FilePath, pub path: FilePath,
pub offset: SyncOffset, pub offset: SyncOffset,
pub language: TrackLanguage, pub language: TrackLanguage,
pub is_default: bool,
pub title: String,
} }
/// Formulário para adicionar uma faixa de áudio externa. /// Formulário para adicionar uma faixa de áudio externa.
@@ -15,6 +17,8 @@ pub struct AddAudioTrackForm {
selected_path: Option<FilePath>, selected_path: Option<FilePath>,
offset_field: SyncOffsetField, offset_field: SyncOffsetField,
language_field: LanguageField, language_field: LanguageField,
is_default: bool,
title_input: String,
} }
impl AddAudioTrackForm { impl AddAudioTrackForm {
@@ -23,6 +27,8 @@ impl AddAudioTrackForm {
selected_path: None, selected_path: None,
offset_field: SyncOffsetField::new(SyncOffset::default()), offset_field: SyncOffsetField::new(SyncOffset::default()),
language_field: LanguageField::new(None), language_field: LanguageField::new(None),
is_default: false,
title_input: String::new(),
} }
} }
@@ -48,6 +54,22 @@ impl AddAudioTrackForm {
self.offset_field.ui(ui, "Atraso:"); self.offset_field.ui(ui, "Atraso:");
self.language_field.ui(ui); self.language_field.ui(ui);
ui.horizontal(|ui| {
ui.label("Nome da faixa:");
ui.text_edit_singleline(&mut self.title_input)
.on_hover_text(
"Nome exibido no player (ex: \"Português\", \"Comentários\").\n\
Deixe vazio para remover o nome original do arquivo fonte.\n\
Recomendado para evitar nomes como \"ISO Media file produced by Google Inc.\"",
);
});
ui.checkbox(&mut self.is_default, "Definir como faixa padrão")
.on_hover_text(
"Marca esta faixa como padrão no container MKV.\n\
Recomendado para que players como Jellyfin selecionem este áudio automaticamente.",
);
ui.add_space(4.0); ui.add_space(4.0);
let can_add = self.selected_path.is_some(); let can_add = self.selected_path.is_some();
@@ -60,11 +82,19 @@ impl AddAudioTrackForm {
self.offset_field.parse(), self.offset_field.parse(),
self.language_field.parse(), self.language_field.parse(),
) { ) {
result = Some(AddAudioTrackRequest { path, offset, language }); result = Some(AddAudioTrackRequest {
path,
offset,
language,
is_default: self.is_default,
title: self.title_input.trim().to_string(),
});
// Reset form // Reset form
self.selected_path = None; self.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default()); self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.language_field = LanguageField::new(None); self.language_field = LanguageField::new(None);
self.is_default = false;
self.title_input = String::new();
} }
} }
}); });
+31 -2
View File
@@ -1,13 +1,15 @@
use eframe::egui;
use crate::adapters::filesystem::FilePickerAdapter; use crate::adapters::filesystem::FilePickerAdapter;
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField}; use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui;
/// Evento emitido ao confirmar adição de legenda. /// Evento emitido ao confirmar adição de legenda.
pub struct AddSubtitleRequest { pub struct AddSubtitleRequest {
pub path: FilePath, pub path: FilePath,
pub offset: SyncOffset, pub offset: SyncOffset,
pub language: TrackLanguage, pub language: TrackLanguage,
pub is_default: bool,
pub title: String,
} }
/// Formulário para adicionar uma faixa de legenda externa. /// Formulário para adicionar uma faixa de legenda externa.
@@ -15,6 +17,8 @@ pub struct AddSubtitleForm {
selected_path: Option<FilePath>, selected_path: Option<FilePath>,
offset_field: SyncOffsetField, offset_field: SyncOffsetField,
language_field: LanguageField, language_field: LanguageField,
is_default: bool,
title_input: String,
} }
impl AddSubtitleForm { impl AddSubtitleForm {
@@ -23,6 +27,8 @@ impl AddSubtitleForm {
selected_path: None, selected_path: None,
offset_field: SyncOffsetField::new(SyncOffset::default()), offset_field: SyncOffsetField::new(SyncOffset::default()),
language_field: LanguageField::new(None), language_field: LanguageField::new(None),
is_default: false,
title_input: String::new(),
} }
} }
@@ -48,6 +54,21 @@ impl AddSubtitleForm {
self.offset_field.ui(ui, "Atraso:"); self.offset_field.ui(ui, "Atraso:");
self.language_field.ui(ui); self.language_field.ui(ui);
ui.horizontal(|ui| {
ui.label("Nome da faixa:");
ui.text_edit_singleline(&mut self.title_input)
.on_hover_text(
"Nome exibido no player (ex: \"Português\", \"Forçada\").\n\
Deixe vazio para remover o nome original do arquivo fonte.",
);
});
ui.checkbox(&mut self.is_default, "Definir como faixa padrão")
.on_hover_text(
"Marca esta faixa como padrão no container MKV.\n\
Recomendado para que players como Jellyfin selecionem esta legenda automaticamente.",
);
ui.add_space(4.0); ui.add_space(4.0);
let can_add = self.selected_path.is_some(); let can_add = self.selected_path.is_some();
@@ -60,10 +81,18 @@ impl AddSubtitleForm {
self.offset_field.parse(), self.offset_field.parse(),
self.language_field.parse(), self.language_field.parse(),
) { ) {
result = Some(AddSubtitleRequest { path, offset, language }); result = Some(AddSubtitleRequest {
path,
offset,
language,
is_default: self.is_default,
title: self.title_input.trim().to_string(),
});
self.selected_path = None; self.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default()); self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.language_field = LanguageField::new(None); self.language_field = LanguageField::new(None);
self.is_default = false;
self.title_input = String::new();
} }
} }
}); });
+16
View File
@@ -49,12 +49,16 @@ struct PendingAudio {
path: FilePath, path: FilePath,
offset: SyncOffset, offset: SyncOffset,
language: TrackLanguage, language: TrackLanguage,
is_default: bool,
title: String,
} }
struct PendingSubtitle { struct PendingSubtitle {
path: FilePath, path: FilePath,
offset: SyncOffset, offset: SyncOffset,
language: TrackLanguage, language: TrackLanguage,
is_default: bool,
title: String,
} }
// ── BatchPanel ───────────────────────────────────────────────────────────────── // ── BatchPanel ─────────────────────────────────────────────────────────────────
@@ -117,11 +121,15 @@ impl BatchPanel {
path: t.path.clone(), path: t.path.clone(),
offset: t.offset, offset: t.offset,
language: t.language.clone(), language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
}), }),
Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle { Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle {
path: t.path.clone(), path: t.path.clone(),
offset: t.offset, offset: t.offset,
language: t.language.clone(), language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
}), }),
} }
} }
@@ -459,6 +467,8 @@ impl BatchPanel {
path: req.path, path: req.path,
offset: req.offset, offset: req.offset,
language: req.language, language: req.language,
is_default: req.is_default,
title: req.title,
}); });
} }
if let Some(req) = subtitle_req { if let Some(req) = subtitle_req {
@@ -466,6 +476,8 @@ impl BatchPanel {
path: req.path, path: req.path,
offset: req.offset, offset: req.offset,
language: req.language, language: req.language,
is_default: req.is_default,
title: req.title,
}); });
} }
@@ -507,6 +519,8 @@ impl BatchPanel {
t.path, t.path,
t.offset, t.offset,
t.language, t.language,
t.is_default,
t.title,
); );
} }
for s in subs { for s in subs {
@@ -515,6 +529,8 @@ impl BatchPanel {
s.path, s.path,
s.offset, s.offset,
s.language, s.language,
s.is_default,
s.title,
); );
} }
if let Some(idx) = self.editing_index { if let Some(idx) = self.editing_index {