Compare commits
2
Commits
c7031cbdce
...
61b71f03a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b71f03a7 | ||
|
|
1f163ca0fc |
@@ -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`)
|
||||
- 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
|
||||
|
||||
- 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-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-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
@@ -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)
|
||||
|
||||
### Domain — Value Objects
|
||||
@@ -200,6 +233,7 @@ rfd = "0.14"
|
||||
- **`Project` rejeita `output == source`** — validado no construtor
|
||||
- **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
|
||||
- **`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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -91,6 +91,18 @@ impl FfmpegCommandBuilder {
|
||||
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) ────────────────────
|
||||
args.push("-c".to_string());
|
||||
args.push("copy".to_string());
|
||||
@@ -115,16 +127,96 @@ impl FfmpegCommandBuilder {
|
||||
Track::Audio(t) => {
|
||||
args.push(format!("-metadata:s:a:{}", ext_audio_idx));
|
||||
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;
|
||||
}
|
||||
Track::Subtitle(t) => {
|
||||
args.push(format!("-metadata:s:s:{}", ext_sub_idx));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────────────
|
||||
args.push(project.output.path.to_string_lossy().to_string());
|
||||
|
||||
@@ -216,6 +308,8 @@ mod tests {
|
||||
FilePath::from("audio.aac"),
|
||||
SyncOffset::from_ms(1200),
|
||||
TrackLanguage::new("por").unwrap(),
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -233,6 +327,8 @@ mod tests {
|
||||
FilePath::from("audio.aac"),
|
||||
SyncOffset::default(),
|
||||
TrackLanguage::new("por").unwrap(),
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -252,6 +348,8 @@ mod tests {
|
||||
FilePath::from("audio.aac"),
|
||||
SyncOffset::default(),
|
||||
TrackLanguage::new("por").unwrap(),
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use crate::domain::entities::{AudioTrack, Project, Track};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Adiciona uma faixa de áudio externa ao projeto.
|
||||
pub struct AddAudioTrack;
|
||||
@@ -11,9 +11,11 @@ impl AddAudioTrack {
|
||||
path: FilePath,
|
||||
offset: SyncOffset,
|
||||
language: TrackLanguage,
|
||||
is_default: bool,
|
||||
title: String,
|
||||
) -> Result<()> {
|
||||
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));
|
||||
Ok(())
|
||||
}
|
||||
@@ -39,6 +41,8 @@ mod tests {
|
||||
FilePath::from("audio_pt.aac"),
|
||||
SyncOffset::default(),
|
||||
lang,
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use crate::domain::entities::{Project, SubtitleTrack, Track};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Adiciona uma faixa de legenda externa ao projeto.
|
||||
pub struct AddSubtitle;
|
||||
@@ -11,9 +11,11 @@ impl AddSubtitle {
|
||||
path: FilePath,
|
||||
offset: SyncOffset,
|
||||
language: TrackLanguage,
|
||||
is_default: bool,
|
||||
title: String,
|
||||
) -> Result<()> {
|
||||
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));
|
||||
Ok(())
|
||||
}
|
||||
@@ -38,6 +40,8 @@ mod tests {
|
||||
FilePath::from("sub_en.srt"),
|
||||
SyncOffset::default(),
|
||||
lang,
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ mod tests {
|
||||
FilePath::from("audio.aac"),
|
||||
SyncOffset::default(),
|
||||
lang,
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let id = project.tracks[0].id();
|
||||
|
||||
@@ -49,6 +49,8 @@ mod tests {
|
||||
FilePath::from("audio_pt.aac"),
|
||||
SyncOffset::from_ms(1200),
|
||||
TrackLanguage::new("por").unwrap(),
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ mod tests {
|
||||
FilePath::from("audio.aac"),
|
||||
SyncOffset::from_ms(0),
|
||||
TrackLanguage::new("por").unwrap(),
|
||||
false,
|
||||
String::new(),
|
||||
)));
|
||||
assert_eq!(project.tracks.len(), 1);
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ mod tests {
|
||||
FilePath::from("audio.aac"),
|
||||
SyncOffset::default(),
|
||||
TrackLanguage::new("por").unwrap(),
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let id = project.tracks[0].id();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Faixa de áudio externa adicionada pelo usuário.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -8,10 +8,31 @@ pub struct AudioTrack {
|
||||
pub path: FilePath,
|
||||
pub offset: SyncOffset,
|
||||
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 {
|
||||
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self {
|
||||
AudioTrack { id, path, offset, language }
|
||||
pub fn new(
|
||||
id: TrackId,
|
||||
path: FilePath,
|
||||
offset: SyncOffset,
|
||||
language: TrackLanguage,
|
||||
is_default: bool,
|
||||
title: String,
|
||||
) -> Self {
|
||||
AudioTrack {
|
||||
id,
|
||||
path,
|
||||
offset,
|
||||
language,
|
||||
is_default,
|
||||
title,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Faixa de legenda externa adicionada pelo usuário.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -8,10 +8,29 @@ pub struct SubtitleTrack {
|
||||
pub path: FilePath,
|
||||
pub offset: SyncOffset,
|
||||
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 {
|
||||
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self {
|
||||
SubtitleTrack { id, path, offset, language }
|
||||
pub fn new(
|
||||
id: TrackId,
|
||||
path: FilePath,
|
||||
offset: SyncOffset,
|
||||
language: TrackLanguage,
|
||||
is_default: bool,
|
||||
title: String,
|
||||
) -> Self {
|
||||
SubtitleTrack {
|
||||
id,
|
||||
path,
|
||||
offset,
|
||||
language,
|
||||
is_default,
|
||||
title,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -718,12 +718,24 @@ impl eframe::App for App {
|
||||
};
|
||||
|
||||
if let Some(req) = audio_req {
|
||||
let _ =
|
||||
AddAudioTrack::execute(project, req.path, req.offset, req.language);
|
||||
let _ = AddAudioTrack::execute(
|
||||
project,
|
||||
req.path,
|
||||
req.offset,
|
||||
req.language,
|
||||
req.is_default,
|
||||
req.title,
|
||||
);
|
||||
}
|
||||
if let Some(req) = subtitle_req {
|
||||
let _ =
|
||||
AddSubtitle::execute(project, req.path, req.offset, req.language);
|
||||
let _ = AddSubtitle::execute(
|
||||
project,
|
||||
req.path,
|
||||
req.offset,
|
||||
req.language,
|
||||
req.is_default,
|
||||
req.title,
|
||||
);
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use eframe::egui;
|
||||
use crate::adapters::filesystem::FilePickerAdapter;
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
|
||||
use eframe::egui;
|
||||
|
||||
/// Evento emitido ao confirmar adição de faixa de áudio.
|
||||
pub struct AddAudioTrackRequest {
|
||||
pub path: FilePath,
|
||||
pub offset: SyncOffset,
|
||||
pub language: TrackLanguage,
|
||||
pub is_default: bool,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Formulário para adicionar uma faixa de áudio externa.
|
||||
@@ -15,6 +17,8 @@ pub struct AddAudioTrackForm {
|
||||
selected_path: Option<FilePath>,
|
||||
offset_field: SyncOffsetField,
|
||||
language_field: LanguageField,
|
||||
is_default: bool,
|
||||
title_input: String,
|
||||
}
|
||||
|
||||
impl AddAudioTrackForm {
|
||||
@@ -23,6 +27,8 @@ impl AddAudioTrackForm {
|
||||
selected_path: None,
|
||||
offset_field: SyncOffsetField::new(SyncOffset::default()),
|
||||
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.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);
|
||||
|
||||
let can_add = self.selected_path.is_some();
|
||||
@@ -60,11 +82,19 @@ impl AddAudioTrackForm {
|
||||
self.offset_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
|
||||
self.selected_path = None;
|
||||
self.offset_field = SyncOffsetField::new(SyncOffset::default());
|
||||
self.language_field = LanguageField::new(None);
|
||||
self.is_default = false;
|
||||
self.title_input = String::new();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use eframe::egui;
|
||||
use crate::adapters::filesystem::FilePickerAdapter;
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
|
||||
use eframe::egui;
|
||||
|
||||
/// Evento emitido ao confirmar adição de legenda.
|
||||
pub struct AddSubtitleRequest {
|
||||
pub path: FilePath,
|
||||
pub offset: SyncOffset,
|
||||
pub language: TrackLanguage,
|
||||
pub is_default: bool,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Formulário para adicionar uma faixa de legenda externa.
|
||||
@@ -15,6 +17,8 @@ pub struct AddSubtitleForm {
|
||||
selected_path: Option<FilePath>,
|
||||
offset_field: SyncOffsetField,
|
||||
language_field: LanguageField,
|
||||
is_default: bool,
|
||||
title_input: String,
|
||||
}
|
||||
|
||||
impl AddSubtitleForm {
|
||||
@@ -23,6 +27,8 @@ impl AddSubtitleForm {
|
||||
selected_path: None,
|
||||
offset_field: SyncOffsetField::new(SyncOffset::default()),
|
||||
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.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);
|
||||
|
||||
let can_add = self.selected_path.is_some();
|
||||
@@ -60,10 +81,18 @@ impl AddSubtitleForm {
|
||||
self.offset_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.offset_field = SyncOffsetField::new(SyncOffset::default());
|
||||
self.language_field = LanguageField::new(None);
|
||||
self.is_default = false;
|
||||
self.title_input = String::new();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,12 +49,16 @@ struct PendingAudio {
|
||||
path: FilePath,
|
||||
offset: SyncOffset,
|
||||
language: TrackLanguage,
|
||||
is_default: bool,
|
||||
title: String,
|
||||
}
|
||||
|
||||
struct PendingSubtitle {
|
||||
path: FilePath,
|
||||
offset: SyncOffset,
|
||||
language: TrackLanguage,
|
||||
is_default: bool,
|
||||
title: String,
|
||||
}
|
||||
|
||||
// ── BatchPanel ─────────────────────────────────────────────────────────────────
|
||||
@@ -117,11 +121,15 @@ impl BatchPanel {
|
||||
path: t.path.clone(),
|
||||
offset: t.offset,
|
||||
language: t.language.clone(),
|
||||
is_default: t.is_default,
|
||||
title: t.title.clone(),
|
||||
}),
|
||||
Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle {
|
||||
path: t.path.clone(),
|
||||
offset: t.offset,
|
||||
language: t.language.clone(),
|
||||
is_default: t.is_default,
|
||||
title: t.title.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -459,6 +467,8 @@ impl BatchPanel {
|
||||
path: req.path,
|
||||
offset: req.offset,
|
||||
language: req.language,
|
||||
is_default: req.is_default,
|
||||
title: req.title,
|
||||
});
|
||||
}
|
||||
if let Some(req) = subtitle_req {
|
||||
@@ -466,6 +476,8 @@ impl BatchPanel {
|
||||
path: req.path,
|
||||
offset: req.offset,
|
||||
language: req.language,
|
||||
is_default: req.is_default,
|
||||
title: req.title,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -507,6 +519,8 @@ impl BatchPanel {
|
||||
t.path,
|
||||
t.offset,
|
||||
t.language,
|
||||
t.is_default,
|
||||
t.title,
|
||||
);
|
||||
}
|
||||
for s in subs {
|
||||
@@ -515,6 +529,8 @@ impl BatchPanel {
|
||||
s.path,
|
||||
s.offset,
|
||||
s.language,
|
||||
s.is_default,
|
||||
s.title,
|
||||
);
|
||||
}
|
||||
if let Some(idx) = self.editing_index {
|
||||
|
||||
Reference in New Issue
Block a user