diff --git a/main.go b/main.go index 2475d32..f46b7df 100644 --- a/main.go +++ b/main.go @@ -997,6 +997,7 @@ type appState struct { subtitleBurnOutput string subtitleBurnEnabled bool subtitleCuesRefresh func() + subtitleTimeOffset float64 } type mergeClip struct { diff --git a/subtitles_module.go b/subtitles_module.go index e41081c..70a95e6 100644 --- a/subtitles_module.go +++ b/subtitles_module.go @@ -37,10 +37,11 @@ type subtitleCue struct { } type subtitlesConfig struct { - OutputMode string `json:"outputMode"` - ModelPath string `json:"modelPath"` - BackendPath string `json:"backendPath"` - BurnOutput string `json:"burnOutput"` + OutputMode string `json:"outputMode"` + ModelPath string `json:"modelPath"` + BackendPath string `json:"backendPath"` + BurnOutput string `json:"burnOutput"` + TimeOffset float64 `json:"timeOffset"` } func defaultSubtitlesConfig() subtitlesConfig { @@ -85,6 +86,7 @@ func (s *appState) applySubtitlesConfig(cfg subtitlesConfig) { s.subtitleModelPath = cfg.ModelPath s.subtitleBackendPath = cfg.BackendPath s.subtitleBurnOutput = cfg.BurnOutput + s.subtitleTimeOffset = cfg.TimeOffset } func (s *appState) persistSubtitlesConfig() { @@ -93,6 +95,7 @@ func (s *appState) persistSubtitlesConfig() { ModelPath: s.subtitleModelPath, BackendPath: s.subtitleBackendPath, BurnOutput: s.subtitleBurnOutput, + TimeOffset: s.subtitleTimeOffset, } if err := savePersistedSubtitlesConfig(cfg); err != nil { logging.Debug(logging.CatSystem, "failed to persist subtitles config: %v", err) @@ -341,11 +344,71 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject { }) applyBtn.Importance = widget.HighImportance + browseVideoBtn := widget.NewButton("Browse", func() { + dialog.ShowFileOpen(func(file fyne.URIReadCloser, err error) { + if err != nil || file == nil { + return + } + defer file.Close() + path := file.URI().Path() + state.subtitleVideoPath = path + videoEntry.SetText(path) + }, state.window) + }) + + browseSubtitleBtn := widget.NewButton("Browse", func() { + dialog.ShowFileOpen(func(file fyne.URIReadCloser, err error) { + if err != nil || file == nil { + return + } + defer file.Close() + path := file.URI().Path() + if err := state.loadSubtitleFile(path); err != nil { + state.setSubtitleStatus(err.Error()) + return + } + subtitleEntry.SetText(path) + rebuildCues() + }, state.window) + }) + + offsetEntry := widget.NewEntry() + offsetEntry.SetPlaceHolder("0.0") + offsetEntry.SetText(fmt.Sprintf("%.2f", state.subtitleTimeOffset)) + offsetEntry.OnChanged = func(val string) { + if offset, err := strconv.ParseFloat(strings.TrimSpace(val), 64); err == nil { + state.subtitleTimeOffset = offset + state.persistSubtitlesConfig() + } + } + + applyOffsetBtn := widget.NewButton("Apply Offset", func() { + state.applySubtitleTimeOffset(state.subtitleTimeOffset) + }) + applyOffsetBtn.Importance = widget.HighImportance + + offsetPlus1Btn := widget.NewButton("+1s", func() { + state.applySubtitleTimeOffset(1.0) + }) + + offsetMinus1Btn := widget.NewButton("-1s", func() { + state.applySubtitleTimeOffset(-1.0) + }) + + offsetPlus01Btn := widget.NewButton("+0.1s", func() { + state.applySubtitleTimeOffset(0.1) + }) + + offsetMinus01Btn := widget.NewButton("-0.1s", func() { + state.applySubtitleTimeOffset(-0.1) + }) + applyControls := func() { outputModeSelect.SetSelected(state.subtitleOutputMode) backendEntry.SetText(state.subtitleBackendPath) modelEntry.SetText(state.subtitleModelPath) outputEntry.SetText(state.subtitleBurnOutput) + offsetEntry.SetText(fmt.Sprintf("%.2f", state.subtitleTimeOffset)) } loadCfgBtn := widget.NewButton("Load Config", func() { @@ -368,6 +431,7 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject { ModelPath: state.subtitleModelPath, BackendPath: state.subtitleBackendPath, BurnOutput: state.subtitleBurnOutput, + TimeOffset: state.subtitleTimeOffset, } if err := savePersistedSubtitlesConfig(cfg); err != nil { dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window) @@ -385,16 +449,25 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject { left := container.NewVBox( widget.NewLabelWithStyle("Sources", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - ui.NewDroppable(videoEntry, handleDrop), - ui.NewDroppable(subtitleEntry, handleDrop), + container.NewBorder(nil, nil, nil, browseVideoBtn, ui.NewDroppable(videoEntry, handleDrop)), + container.NewBorder(nil, nil, nil, browseSubtitleBtn, ui.NewDroppable(subtitleEntry, handleDrop)), + widget.NewSeparator(), + widget.NewLabelWithStyle("Timing Adjustment", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + widget.NewLabel("Shift all subtitle times by offset (seconds):"), + offsetEntry, + container.NewHBox(offsetMinus1Btn, offsetMinus01Btn, offsetPlus01Btn, offsetPlus1Btn), + applyOffsetBtn, + widget.NewSeparator(), widget.NewLabelWithStyle("Offline Speech-to-Text (whisper.cpp)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), backendEntry, modelEntry, container.NewHBox(generateBtn), + widget.NewSeparator(), widget.NewLabelWithStyle("Output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), outputModeSelect, outputEntry, applyBtn, + widget.NewSeparator(), widget.NewLabelWithStyle("Status", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), statusLabel, widget.NewSeparator(), @@ -499,6 +572,27 @@ func (s *appState) saveSubtitleFile(path string) error { return nil } +func (s *appState) applySubtitleTimeOffset(offsetSeconds float64) { + if len(s.subtitleCues) == 0 { + s.setSubtitleStatus("No subtitle cues to adjust") + return + } + for i := range s.subtitleCues { + s.subtitleCues[i].Start += offsetSeconds + s.subtitleCues[i].End += offsetSeconds + if s.subtitleCues[i].Start < 0 { + s.subtitleCues[i].Start = 0 + } + if s.subtitleCues[i].End < 0 { + s.subtitleCues[i].End = 0 + } + } + if s.subtitleCuesRefresh != nil { + s.subtitleCuesRefresh() + } + s.setSubtitleStatus(fmt.Sprintf("Applied %.2fs offset to %d subtitle cues", offsetSeconds, len(s.subtitleCues))) +} + func (s *appState) generateSubtitlesFromSpeech() { videoPath := strings.TrimSpace(s.subtitleVideoPath) if videoPath == "" {