package tui import ( "context" "errors" "fmt" "strings" "time" "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/version" ) func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: if m.operationQuitAfter { return m, nil } if m.operationActive && m.state != StateInstalling { // A preset or other config commit may still own streams outside // the installation screen. Do not navigate into another mutation // and overwrite that ownership before its terminal result drains. if keyCode(msg) == keyCtrlC { m.requestOperationCancellation(false, true) } else if m.presetApplyInFlight { m.presetStatusMessage = "Finishing preset; waiting for the file update…" } return m, nil } if filterCmd, handled := m.handleActiveListFilterKey(msg); handled { return m, filterCmd } if m.state == StateInstalling && m.handleInstallOutputScroll(keyCode(msg)) { return m, nil } switch keyCode(msg) { case keyCtrlC: if m.state == StateInstalling && !m.installComplete { m.requestOperationCancellation(false, true) return m, nil } if m.preparedManagerUpdate != nil { _ = m.preparedManagerUpdate.Abort() m.preparedManagerUpdate = nil } return m, tea.Quit case keyCtrlH: m.showHelp = !m.showHelp return m, nil case keyCtrlV: m.showVersion = !m.showVersion return m, nil case tea.KeyEnter: if m.state == StateError { return m, tea.Quit } else if m.state == StateMainMenu && !m.installing { selected := m.menuList.SelectedItem() if selected != nil { item := selected.(menuItem) m.selectedMethod = item.title m.selectedMethodID = item.id m.statusMessage = item.title switch m.selectedMethodID { case "updates": m.state = StateUpdates m.updateCheckResult = nil m.updateItems = []list.Item{} m.installerCheckType = "manager" if m.releaseInfoLoading { m.pendingUpdatesCheck = true return m, nil } return m, m.checkForUpdatesCmd(m.installer) case "mpv-manager": m.setupInstallMenuList() m.state = StateInstallMenu m.statusMessage = "Select Installation Method" return m, nil case "unmpv-manager": m.state = StateUninstall return m, refreshUninstallList() case "install-to-path": m.state = StateInstallToPath case "set-language-preferences": m.loadLanguageData() m.setupLangPreferenceList() m.state = StateLanguagePreferences return m, nil case "config-options": m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil case "hotkeys": m.setupHotkeysPresetList() m.state = StateHotkeysPreset m.statusMessage = "Keybinding Presets" return m, nil case "web-ui": m.setupWebUIList() m.state = StateWebUIMenu return m, nil case "job-history": m.setupJobHistoryList() m.state = StateJobHistory return m, nil case "view-logs": m.setupLogsView() m.state = StateLogsViewer return m, nil } } } else if m.state == StateInstallMenu && !m.installing { selected := m.installMenuList.SelectedItem() if selected != nil { item := selected.(menuItem) m.selectedMethod = item.title m.selectedMethodID = item.id m.statusMessage = item.title if methodRequiresReleaseInfo(item.id) && m.releaseInfoLoading { m.statusMessage = "Release information is still loading; try again in a moment." return m, nil } if methodRequiresReleaseInfo(item.id) && m.releaseInfoStatus != "" { m.statusMessage = "This installation method is unavailable offline." return m, nil } // Check if this method needs UI selection if constants.SupportsMPVOverlay(m.selectedMethodID) { m.uiChangeMode = false m.selectedUIApp = nil m.pendingMethodID = m.selectedMethodID m.pendingAppName = getAppNameForMethod(m.selectedMethodID) m.setupUISelectList() m.state = StateUISelect return m, nil } // Direct installation for non-MPV methods (IINA, MPC-QT, Celluloid) m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false m.installOperationType = OpTypeInstall installDir := m.getInstallPath() // Use default UI type for direct installations return m, m.executeInstallCmd(m.selectedMethodID, installDir, true, m.platformInfo, m.installer) } } else if m.state == StateUninstall && !m.installing { selected := m.uninstallList.SelectedItem() if selected != nil { appItem := selected.(uninstallAppItem) m.selectedUninstall = appItem.app m.statusMessage = appItem.app.AppName m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false return m, m.executeUninstallCmd(appItem.app, m.platformInfo, m.installer) } } else if m.state == StateUpdates && !m.installing { selected := m.updateList.SelectedItem() if selected != nil { switch item := selected.(type) { case menuItem: if item.id == "update-installer" { if version.SelfUpdateDisabled == "true" { // Self-update is disabled, don't enter update state return m, nil } m.state = StateUpdateInstaller return m, m.checkForUpdateCmd() } else if item.id == "load-config" { m.state = StateInstalling m.loadingConfig = true m.installProgress = 0 m.installing = true m.installComplete = false outputChan := make(chan string, 100) installDone := make(chan error, 1) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, func(_ context.Context, cr *installer.CommandRunner) error { return m.installer.InstallMPVConfigWithOutput(cr) }) return m, m.ownOperation(saveChannelsMsg{outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard}) } case updateItem: if item.UpdateType == "update" { m.selectedMethodID = item.MethodID m.selectedMethod = item.AppName m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false m.selectedUpdateItem = &item m.installOperationType = OpTypeUpdate return m, m.executeUpdateCmd(item, m.platformInfo, m.installer) } else if item.UpdateType == "package-update" { m.selectedMethodID = item.MethodID m.selectedMethod = item.AppName m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false m.selectedUpdateItem = &item m.installOperationType = OpTypeUpdate return m, m.executeUpdateCmd(item, m.platformInfo, m.installer) } else if item.UpdateType == "package-info" { m.statusMessage = fmt.Sprintf("%s is managed by %s; update it with that package manager.", item.AppName, item.PackageManager) return m, nil } else if item.UpdateType == "current-version" { m.statusMessage = item.AppName + " is already current." return m, nil } case configBackupItem: m.selectedRestoreBackup = item.backup m.state = StateRestoreConfig items := make([]list.Item, len(m.updateCheckResult.ConfigBackupItems)) for i, backup := range m.updateCheckResult.ConfigBackupItems { items[i] = configBackupItem{backup: &backup} } m.restoreConfigList = m.createStyledList("Select Backup to Restore", items) return m, nil } } } else if m.state == StateRestoreConfig && !m.installing { selected := m.restoreConfigList.SelectedItem() if selected != nil { backupItem := selected.(configBackupItem) m.selectedRestoreBackup = backupItem.backup m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false m.installOperationType = OpTypeConfig m.currentCommand = fmt.Sprintf("Restoring config from %s", m.selectedRestoreBackup.BackupName) return m, m.runRestoreConfigCmd() } } else if m.state == StateInstallToPath && !m.installing { m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false m.selectedMethodID = "install-to-path" isRemoving := isInstallMPVInPATH() return m, m.executePathCmd(isRemoving, m.installer) } case tea.KeyEsc: if m.state == StateError { m.state = StateMainMenu m.errorMessage = "" m.installOutput = []string{} m.currentCommand = "" return m, nil } else if m.state == StateInstallMenu { m.resetToMainMenu() return m, nil } else if m.state == StateInstalling { if !m.installComplete { m.requestOperationCancellation(true, false) return m, nil } if m.preparedManagerUpdate != nil { _ = m.preparedManagerUpdate.Abort() m.preparedManagerUpdate = nil } m.resetToMainMenu() return m, nil } else if m.state == StateSuccess || m.state == StateUpdateInstaller || m.state == StateUpdateList || m.state == StateUninstall || m.state == StateInstallToPath { m.resetToMainMenu() m.RefreshMainMenu() return m, nil } else if m.state == StateUpdates { m.resetToMainMenu() return m, nil } else if m.state == StateConfigOptions { m.resetToMainMenu() return m, nil } else if m.state == StateHWAOptions { m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil } else if m.state == StateScreenshotOptions { m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil } else if m.state == StateRestoreConfig { m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil } else if m.state == StateFileAssociations { m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil } else if m.state == StateFileAssociationsMenu { m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil } case tea.KeyExtended: if m.state == StateUpdateInstaller && len(keyTextRunes(msg)) == 1 { switch string(keyTextRunes(msg)[0]) { case "u", "U": if version.SelfUpdateDisabled == "true" { // Self-update is disabled, don't allow update return m, nil } if m.versionCheckResult != nil && m.versionCheckResult.UpdateAvailable { m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false m.installError = nil m.installSuccess = false m.installOperationType = OpTypeManagerUpdate m.managerUpdateReady = false m.preparedManagerUpdate = nil m.installStartedAt = time.Now() m.downloadWritten = 0 m.downloadTotal = 0 m.currentCommand = "Updating MPV Manager" selectedUpdate := *m.versionCheckResult outputChan := make(chan string, 100) installDone := make(chan error, 1) progressChan := make(chan downloadProgressMsg, 10) preparedChan := make(chan *version.PreparedSelfUpdate) ctx, cancel := context.WithCancel(context.Background()) resolveExecutable := m.managerExecutable prepareUpdate := m.prepareManagerUpdate execPath, executableErr := resolveExecutable() launchManagerUpdateWorker(outputChan, installDone, progressChan, preparedChan, func() error { if executableErr != nil { outputChan <- fmt.Sprintf("Error: Failed to get executable path: %v", executableErr) return executableErr } lastPhase := version.SelfUpdatePhase("") progressCallback := func(progress version.SelfUpdateProgress) { progressMessage := downloadProgressMsg{ written: progress.Written, total: progress.Total, phase: progress.Phase, message: progress.Message, } select { case progressChan <- progressMessage: case <-ctx.Done(): return } if progress.Phase != lastPhase && progress.Message != "" { select { case outputChan <- progress.Message: case <-ctx.Done(): return } lastPhase = progress.Phase } } prepared, err := prepareUpdate(ctx, execPath, &selectedUpdate, progressCallback) if err != nil { outputChan <- fmt.Sprintf("Error: Failed to update manager: %v", err) return err } preparedChan <- prepared outputChan <- "βœ“ Update downloaded and verified." outputChan <- "" outputChan <- "Press ENTER to install and restart MPV Manager..." return nil }) return m, m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, progressChan: progressChan, preparedChan: preparedChan, cancel: cancel, }) } } } } case tea.WindowSizeMsg: m.resizeForTerminal(msg.Width, msg.Height) case installErrorMsg: m.state = StateError m.errorMessage = sanitizeTerminalText(msg.err.Error()) m.installing = false return m, nil case versionCheckMsg: m.versionCheckResult = msg.result return m, nil case releaseInfoLoadedMsg: m.releaseInfoLoading = false m.versionCheckResult = msg.versionResult if msg.err != nil { m.releaseInfoStatus = "Offline mode: release downloads and updates are unavailable." log.Warn("TUI release refresh failed: " + msg.err.Error()) } else if msg.info == nil { m.releaseInfoStatus = "Offline mode: release information was empty." } else { m.releaseInfoStatus = "" if m.installer != nil { m.installer.ReleaseInfo = *msg.info } } if m.pendingUpdatesCheck { m.pendingUpdatesCheck = false return m, m.checkForUpdatesCmd(m.installer) } return m, nil case updateCheckResultMsg: m.updateCheckResult = msg.result items := make([]list.Item, 0, 2+len(msg.result.AppsToUpdate)) // Only show "Update MPV Manager" option when self-update is not disabled if version.SelfUpdateDisabled != "true" { installerItem := menuItem{ id: "update-installer", title: "πŸ”„ Update MPV Manager", } if m.versionCheckResult != nil { if m.versionCheckResult.UpdateAvailable { installerItem.title = "πŸ”„ Update MPV Manager " + successStyle.Render("[ Update Available ]") installerItem.description = fmt.Sprintf("Current: %s - Available: %s - Select to update", m.versionCheckResult.CurrentVersion, m.versionCheckResult.LatestVersion) } else if m.versionCheckResult.Error == nil { installerItem.description = fmt.Sprintf("Current: %s - Latest", m.versionCheckResult.CurrentVersion) } else { installerItem.description = "Error checking for updates" } } else { installerItem.description = "Checking for manager updates..." } items = append(items, installerItem) } items = append(items, menuItem{ id: "load-config", title: "πŸ“₯ Load Latest Recommended MPV Config", description: "Install the latest recommended mpv.conf configuration", }) for _, app := range msg.result.AppsToUpdate { items = append(items, updateItem{ AppID: app.AppID, AppName: app.AppName, InstallPath: app.InstallPath, CurrentVersion: app.CurrentVersion, AvailableVersion: app.AvailableVersion, UpdateType: app.UpdateType, PackageManager: app.PackageManager, MethodID: app.MethodID, }) } m.updateList = m.createStyledList("Available Updates", items) return m, nil case uninstallAppsLoadedMsg: items := make([]list.Item, len(msg.apps)) for i, app := range msg.apps { items[i] = uninstallAppItem{app: &app} } m.uninstallList.SetItems(items) m.uninstallList.Title = "Installed Applications" m.uninstallList.Styles.Title = titleStyle return m, nil case refreshMenuMsg: m.RefreshMainMenu() return m, tea.Tick(10*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg{} }) case cmdStartMsg: m.currentCommand = sanitizeTerminalText(msg.command) m.installStartedAt = time.Now() m.appendInstallOutput("", fmt.Sprintf("Running: %s", msg.command), strings.Repeat("─", 50)) m.updateOutputViewport() return m, nil case saveChannelsMsg: return m, m.ownOperation(msg) case operationReadyMsg: if m.operationRead != msg.read { return m, nil } m.operationRead = nil return m.Update(msg.read.msg) case shutdownMsg: m.cancelObservations() if m.operationActive || (m.state == StateInstalling && !m.installComplete) { m.requestOperationCancellation(false, true) return m, nil } if m.preparedManagerUpdate != nil { _ = m.preparedManagerUpdate.Abort() m.preparedManagerUpdate = nil } return m, tea.Quit case operationStreamClosedMsg: switch msg.kind { case operationStreamOutput: m.outputChan = nil case operationStreamResult: m.installDone = nil case operationStreamProgress: m.progressChan = nil case operationStreamPrepared: m.managerPreparedChan = nil } return m, m.nextOperationStreamCmd() case operationResultMsg: m.installDone = nil m.operationResultReceived = true if msg.ok { m.operationResult = msg.err } else { m.operationResult = fmt.Errorf("operation result stream closed without a result") } return m, m.nextOperationStreamCmd() case managerUpdatePreparedMsg: m.preparedManagerUpdate = msg.prepared return m, m.nextOperationStreamCmd() case postUpdateRenderReadyMsg: journalPath := m.postUpdateJournal acknowledge := m.acknowledgeSelfUpdate return m, func() tea.Msg { return postUpdateHealthAcknowledgedMsg{err: acknowledge(journalPath)} } case postUpdateHealthAcknowledgedMsg: if msg.err != nil { log.Error("Post-update TUI readiness acknowledgement failed: " + msg.err.Error()) m.errorMessage = "Post-update readiness acknowledgement failed: " + msg.err.Error() m.state = StateError return m, tea.Quit } m.postUpdateJournal = "" return m, nil case downloadProgressMsg: m.downloadWritten = msg.written m.downloadTotal = msg.total if msg.message != "" { m.currentCommand = sanitizeTerminalText(msg.message) } if m.downloadTotal > 0 { percent := float64(m.downloadWritten) / float64(m.downloadTotal) * 100 m.installProgress = int(percent) } else if msg.phase != "" { m.installProgress = tuiManagerUpdateProgress(msg.phase) } return m, m.nextOperationStreamCmd() case cmdOutputMsg: m.appendInstallOutput(msg.line) m.scheduleOutputViewportRefresh() return m, m.nextOperationStreamCmd() case cmdDoneMsg: m.refreshOutputViewport() var setupErr *installer.PartialInstallError if errors.As(msg.err, &setupErr) { msg.err = &partialOperationError{operation: "Installation", cause: setupErr, guidance: "The application is present, but setup did not finish. Resolve the error, then adopt or reconcile the installation."} } cancelRequested := m.operationCancelRequested returnToMenu := m.operationReturnToMenu quitAfter := m.operationQuitAfter if cancelRequested && msg.err != nil { msg.err = &operationCancelledError{cause: msg.err} } m.installComplete = true m.installing = false m.loadingConfig = false m.installError = msg.err if postInstallMsg, ok := normalizePostInstallMessage(msg.err); ok { m.postInstallMessage = postInstallMsg.Message m.installError = nil msg.err = nil } if msg.err == nil && m.installOperationType == OpTypeManagerUpdate && m.preparedManagerUpdate == nil { msg.err = fmt.Errorf("manager update preparation completed without a transaction") m.installError = msg.err } if msg.err == nil { m.installSuccess = true if m.installOperationType == OpTypeManagerUpdate { m.managerUpdateReady = true } // Handle successful install/update if m.selectedUninstall != nil { // βœ… SUCCESSFUL UNINSTALL log.Separator("Uninstallation Summary") log.Info(fmt.Sprintf("Uninstallation completed successfully: %s", m.selectedUninstall.AppName)) var trackingErr error if m.selectedUninstall.ID != "" { trackingErr = config.RemoveInstalledAppByID(m.selectedUninstall.ID) } else { // Compatibility for transient legacy records created before IDs // were migrated. trackingErr = config.RemoveInstalledApp(m.selectedUninstall.AppName) } m.RefreshMainMenu() if trackingErr != nil { m.markTrackingFailure("Uninstallation", trackingErr, "The application was removed, but its installed-app record could not be updated. Fix config permissions and reconcile the installed-app list.") } else { log.Info("Removed from installed_apps config") m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "βœ“ Uninstallation completed successfully!") m.installOutput = append(m.installOutput, fmt.Sprintf("Removed: %s", m.selectedUninstall.AppName)) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "Press Enter to return to main menu or Esc to quit") m.updateOutputViewport() } } else if m.selectedMethodID == "install-to-path" { log.Separator("PATH Operation Summary") pathInfo, _ := currentManagerPATHInfo() inPath := isInstallMPVInPATH() if inPath { log.Info("mpv-manager added to system PATH successfully") log.Info(fmt.Sprintf("Binary location: %s", pathInfo.BinaryPath)) log.Info(fmt.Sprintf("Alias location: %s", pathInfo.AliasPath)) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "βœ“ mpv-manager added to system PATH successfully!") m.installOutput = append(m.installOutput, fmt.Sprintf(" Binary: %s", pathInfo.BinaryPath)) m.installOutput = append(m.installOutput, fmt.Sprintf(" Alias: %s", pathInfo.AliasPath)) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "You can now run 'mpv-manager' or 'mpv-install' from anywhere!") m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "Press Enter to return to main menu or Esc to quit") m.updateOutputViewport() } else { log.Info("mpv-manager removed from system PATH successfully") m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "βœ“ mpv-manager removed from system PATH successfully!") m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "Press Enter to return to main menu or Esc to quit") m.updateOutputViewport() } m.RefreshMainMenu() } else { // Handle successful install switch m.installOperationType { case OpTypeInstall: log.Separator("Installation Summary") log.Info(fmt.Sprintf("Installation completed successfully: %s", getClientName(m.selectedMethodID, m.selectedMethod))) if err := m.saveInstalledApp(); err != nil { m.markTrackingFailure("Installation", err, "The application is present on disk, but MPV Manager could not save its installed-app record. Fix config permissions and adopt/reconcile the installation.") } else { log.Info(fmt.Sprintf("App saved to config: %s", m.selectedMethodID)) } m.RefreshMainMenu() case OpTypeUpdate: log.Separator("Update Summary") log.Info(fmt.Sprintf("Update completed successfully: %s", getClientName(m.selectedMethodID, m.selectedMethod))) if err := m.updateInstalledAppVersion(); err != nil { m.markTrackingFailure("Update", err, "The application update completed, but MPV Manager could not persist its new version. Fix config permissions and refresh/reconcile the installed-app record.") } m.RefreshMainMenu() case OpTypeConfig: log.Separator("Config Operation Summary") log.Info("Configuration operation completed successfully") m.RefreshMainMenu() case OpTypeManagerUpdate: log.Separator("Manager Update Summary") log.Info("MPV Manager update verified and staged; helper handoff required") case OpTypeUIChange: log.Separator("UI Change Summary") if m.selectedUIApp == nil { m.markTrackingFailure("UI change", fmt.Errorf("selected app identity was lost"), "The UI operation finished but its target identity is unavailable. Refresh installed apps and reconcile the selected UI.") break } log.Info(fmt.Sprintf("UI change completed successfully for app ID %s", m.selectedUIApp.ID)) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "βœ“ UI changed successfully for "+m.selectedUIApp.AppName) m.installOutput = append(m.installOutput, "Press Enter to return to the main menu") m.updateOutputViewport() m.RefreshMainMenu() default: log.Warn("Unknown operation type completed") m.RefreshMainMenu() } } } else { // βœ… HANDLE ERRORS PROPERLY m.installSuccess = false var cancelledErr *operationCancelledError var partialErr *partialOperationError if errors.As(msg.err, &partialErr) { m.installError = partialErr log.Error(partialErr.Error()) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "⚠ "+partialErr.operation+" needs reconciliation") m.installOutput = append(m.installOutput, "Error: "+partialErr.cause.Error()) m.installOutput = append(m.installOutput, partialErr.guidance) m.installOutput = append(m.installOutput, "Press Enter to return to the main menu") m.updateOutputViewport() m.RefreshMainMenu() } else if errors.As(msg.err, &cancelledErr) { log.Info("Operation cancellation acknowledged after worker shutdown") m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "⊘ Operation cancelled") m.installOutput = append(m.installOutput, "The worker stopped and all remaining output was drained.") m.installOutput = append(m.installOutput, "Verify external package-manager state before retrying if a command had already begun.") m.updateOutputViewport() } else if m.selectedUninstall != nil { // ❌ FAILED UNINSTALL log.Separator("Uninstallation Error") log.Error(fmt.Sprintf("Uninstallation failed: %v", msg.err)) log.Info("App NOT removed from installed_apps config (uninstall did not complete)") // Show error message to user m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "βœ— Uninstallation failed!") m.installOutput = append(m.installOutput, fmt.Sprintf("Error: %v", msg.err)) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "Press Enter to return to main menu or Esc to quit") m.updateOutputViewport() // Don't remove from config - app wasn't actually uninstalled // App will stay in menu for user to try again m.RefreshMainMenu() } else { // Handle failed install/update operationName := "Installation" if m.installOperationType == OpTypeManagerUpdate { operationName = "Manager update" } else if m.installOperationType == OpTypeUpdate { operationName = "Update" } else if m.installOperationType == OpTypeUIChange { operationName = "UI change" } log.Error(fmt.Sprintf("%s failed: %s", operationName, msg.err.Error())) // Show error message to user m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "βœ— "+operationName+" failed!") m.installOutput = append(m.installOutput, fmt.Sprintf("Error: %v", msg.err)) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "Press Enter to return to main menu or Esc to quit") m.updateOutputViewport() } } // Record history only after authoritative metadata reaches its final // outcome. Physical success followed by a tracking failure is therefore // recorded as a reconciliation error, never unconditional success. m.recordJobHistory(m.installError) m.clearOperationRuntime() if (returnToMenu || quitAfter) && m.preparedManagerUpdate != nil { _ = m.preparedManagerUpdate.Abort() m.preparedManagerUpdate = nil } if quitAfter { return m, tea.Quit } if returnToMenu { m.resetToMainMenu() m.statusMessage = "Operation stopped" return m, nil } return m, nil case presetApplyMsg: quitAfter := m.operationQuitAfter m.clearOperationRuntime() m.installComplete = true m.presetApplyInFlight = false if msg.err != nil { m.presetStatusMessage = fmt.Sprintf("βœ— Failed to apply preset: %v", msg.err) } else { m.presetStatusMessage = fmt.Sprintf("βœ“ Applied preset: %s", msg.preset) } if quitAfter { return m, tea.Quit } return m, nil case tickMsg: case tea.MouseWheelMsg: if m.state == StateInstalling { switch msg.Button { case tea.MouseWheelDown: m.outputViewport.ScrollDown(1) m.autoScroll = m.outputViewport.AtBottom() case tea.MouseWheelUp: m.outputViewport.ScrollUp(1) m.autoScroll = false } } } if (m.state == StateMainMenu || m.state == StateInstallMenu || m.state == StateUninstall) && !m.installing { if m.state == StateMainMenu { m.menuList, cmd = m.menuList.Update(msg) } else if m.state == StateInstallMenu { m.installMenuList, cmd = m.installMenuList.Update(msg) } else if m.state == StateUninstall { m.uninstallList, cmd = m.uninstallList.Update(msg) } } // Delegate language state handling switch m.state { case StateLanguagePreferences: m, cmd = m.handleLangPreferenceUpdate(msg) return m, cmd case StateLanguageMajorSelect: m, cmd = m.handleMajorLangUpdate(msg) return m, cmd case StateLanguageRegionSelect: m, cmd = m.handleRegionLangUpdate(msg) return m, cmd case StateLanguageSelectedList: m, cmd = m.handleSelectedLangUpdate(msg) return m, cmd case StateLanguageConfirm: switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEnter: m.state = StateMainMenu return m, nil } } return m, nil case StateConfigOptions: m, cmd = m.handleConfigOptionsUpdate(msg) return m, cmd case StateHWAOptions: m, cmd = m.handleHWAOptionsUpdate(msg) return m, cmd case StateScreenshotOptions: m, cmd = m.handleScreenshotSettingsUpdate(msg) return m, cmd // Screenshot value selection states (dropdown lists) case StateScreenshotFormatEdit: fallthrough case StateScreenshotTagColorspaceEdit: fallthrough case StateScreenshotHighBitDepthEdit: fallthrough case StateScreenshotJpegQualityEdit: fallthrough case StateScreenshotPngCompressionEdit: fallthrough case StateScreenshotWebpLosslessEdit: fallthrough case StateScreenshotWebpQualityEdit: fallthrough case StateScreenshotJxlDistanceEdit: fallthrough case StateScreenshotAvifEncoderEdit: fallthrough case StateScreenshotAvifPixfmtEdit: m, cmd = m.handleScreenshotValueSelection(msg) return m, cmd // Screenshot text input states case StateScreenshotTemplateEdit: fallthrough case StateScreenshotDirectoryEdit: fallthrough case StateScreenshotAvifOptsEdit: m, cmd = m.handleScreenshotTextInput(msg) return m, cmd case StateSavePositionOnQuitEdit: m, cmd = m.handleSavePositionOnQuitUpdate(msg) return m, cmd case StateFileAssociationsMenu: m, cmd = m.handleFileAssociationsMenuUpdate(msg) return m, cmd case StateWebUIMenu: switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEnter: if !m.installing { selected := m.webUIList.SelectedItem() if selected != nil { item := selected.(menuItem) m.selectedMethodID = item.id m.statusMessage = item.title switch m.selectedMethodID { case "create-webui-shortcut": return m, m.runCreateWebUIShortcutCmd() case "toggle-webui-shortcut": if err := config.SetCreateManagerShortcut(!config.GetCreateManagerShortcut()); err != nil { m.webUIStatusMsg = "βœ— Failed to save shortcut preference: " + err.Error() } else { m.webUIStatusMsg = "βœ“ Shortcut preference saved" } m.setupWebUIList() return m, nil } } } case tea.KeyEsc: m.state = StateMainMenu m.statusMessage = "Main Menu" m.webUIStatusMsg = "" } } m.webUIList, cmd = m.webUIList.Update(msg) return m, cmd case StateHotkeysCategory: m, cmd = m.handleHotkeysCategoryUpdate(msg) return m, cmd case StateHotkeys: m, cmd = m.handleHotkeysUpdate(msg) return m, cmd case StateHotkeysPreset: m, cmd = m.handleHotkeysPresetUpdate(msg) return m, cmd case StateHotkeysBindings: m, cmd = m.handleHotkeysBindingsUpdate(msg) return m, cmd case StateHotkeysBindingEdit, StateHotkeysBindingAddKey, StateHotkeysBindingAddCmd: m, cmd = m.handleBindingTextInputUpdate(msg) return m, cmd case StateModernZOptions: m, cmd = m.handleModernZOptionsUpdate(msg) return m, cmd case StateModernZOptionEdit: m, cmd = m.handleModernZOptionEditUpdate(msg) return m, cmd case StateUOSCOptions: m, cmd = m.handleUOSCOptionsUpdate(msg) return m, cmd case StateUOSCOptionEdit: m, cmd = m.handleUOSCOptionEditUpdate(msg) return m, cmd case StateInstallPath: m, cmd = m.handleInstallPathUpdate(msg) return m, cmd case StateInstallPathEdit: m, cmd = m.handleInstallPathEditUpdate(msg) return m, cmd case StateJobHistory: m, cmd = m.handleJobHistoryUpdate(msg) return m, cmd case StateJobHistoryDetail: m, cmd = m.handleJobHistoryDetailUpdate(msg) return m, cmd case StateResetManagerData: m, cmd = m.handleResetManagerDataUpdate(msg) return m, cmd case StateLogsViewer: m, cmd = m.handleLogsViewerUpdate(msg) return m, cmd case StateUISelect: m, cmd = m.handleUISelectUpdate(msg) return m, cmd case StateUIAppSelect: m, cmd = m.handleUIAppSelectUpdate(msg) return m, cmd case StateInstalling: if m.installComplete { switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEnter: if m.installOperationType == OpTypeManagerUpdate && m.managerUpdateReady { m.currentCommand = "Restarting MPV Manager" m.installError = nil m.installOutput = append(m.installOutput, "Restarting MPV Manager...") m.updateOutputViewport() if m.launchManagerUpdate == nil || m.preparedManagerUpdate == nil { m.installError = fmt.Errorf("restart launcher is unavailable") m.installSuccess = false m.installOutput = append(m.installOutput, "βœ— Restart failed: "+m.installError.Error()) return m, nil } if err := m.launchManagerUpdate(m.preparedManagerUpdate, "tui"); err != nil { m.installError = fmt.Errorf("launch updated manager: %w", err) m.installSuccess = false m.currentCommand = "Restart failed" m.installOutput = append(m.installOutput, "βœ— Restart failed: "+err.Error()) m.installOutput = append(m.installOutput, "Press Enter to try restarting again.") m.updateOutputViewport() return m, nil } return m, tea.Quit } m.resetToMainMenu() return m, nil case tea.KeyEsc: if m.installOperationType == OpTypeManagerUpdate && m.managerUpdateReady && m.preparedManagerUpdate != nil { _ = m.preparedManagerUpdate.Abort() } m.resetToMainMenu() return m, nil } } } return m, nil case StateUpdates: if m.updateList.SettingFilter() || m.updateList.IsFiltered() { switch msg := msg.(type) { case tea.KeyPressMsg: if keyCode(msg) == tea.KeyEsc { m.updateList.ResetFilter() return m, nil } } } var cmd tea.Cmd m.updateList, cmd = m.updateList.Update(msg) return m, cmd case StateUpdateList: if m.updateList.SettingFilter() || m.updateList.IsFiltered() { switch msg := msg.(type) { case tea.KeyPressMsg: if keyCode(msg) == tea.KeyEsc { m.updateList.ResetFilter() return m, nil } } } var cmd tea.Cmd m.updateList, cmd = m.updateList.Update(msg) return m, cmd case StateRestoreConfig: if m.restoreConfigList.SettingFilter() || m.restoreConfigList.IsFiltered() { switch msg := msg.(type) { case tea.KeyPressMsg: if keyCode(msg) == tea.KeyEsc { m.restoreConfigList.ResetFilter() return m, nil } } } var cmd tea.Cmd m.restoreConfigList, cmd = m.restoreConfigList.Update(msg) return m, cmd } return m, cmd } func (m *Model) saveInstalledApp() error { if m.selectedMethodID == "install-to-path" { return nil } appName := getClientName(m.selectedMethodID, m.selectedMethod) installType := m.getInstallType() installPath := installer.RecordedInstallPath(m.selectedMethodID, m.getInstallPath()) // Get version from appropriate source // Note: Don't save version for package-manager and flatpak // since these can be updated outside of our app appVersion := "" if installType != "package-manager" && installType != "flatpak" { appVersion = m.getInstalledAppVersion() } app := config.InstalledApp{ AppName: appName, AppType: installType, InstallMethod: m.selectedMethodID, InstallPath: installPath, InstalledDate: time.Now().Format("2006-01-02 15:04:05"), AppVersion: appVersion, Managed: true, // Installed and managed by MPV Manager } if err := config.AddInstalledApp(app); err != nil { return fmt.Errorf("save installed app: %w", err) } return nil } func (m *Model) getInstallType() string { if meta, ok := MethodIDMetadata[m.selectedMethodID]; ok { return meta.Type } return "" } func (m *Model) getInstalledAppVersion() string { if m.installer != nil { if meta, ok := MethodIDMetadata[m.selectedMethodID]; ok && meta.Version != "" { switch meta.Version { case "mpv": return m.installer.ReleaseInfo.MpvVersion case "mpc-qt": return m.installer.ReleaseInfo.MPCQT.AppVersion case "iina": return m.installer.ReleaseInfo.IINA.AppVersion } } } return "" } func (m *Model) getInstallPath() string { if meta, ok := MethodIDMetadata[m.selectedMethodID]; ok { if meta.Type == "exe-binary" || meta.Type == "exe-installer" { return config.GetInstallPath() } } return "" } func (m *Model) updateInstalledAppVersion() error { appVersion := m.getInstalledAppVersion() if appVersion == "" { return nil } var app config.InstalledApp var err error if m.selectedUpdateItem != nil && m.selectedUpdateItem.AppID != "" { app, err = config.UpdateInstalledAppVersionByID(m.selectedUpdateItem.AppID, appVersion) } else { app, err = config.UpdateInstalledAppVersionByMethod(m.selectedMethodID, appVersion) } if err != nil { return fmt.Errorf("update app version for %s: %w", m.selectedMethodID, err) } log.Separator("Update Summary") log.Info(fmt.Sprintf("Version updated successfully: %s", app.AppName)) log.Info(fmt.Sprintf("New version: %s", appVersion)) return nil } func (m *Model) markTrackingFailure(operation string, err error, guidance string) { m.installSuccess = false m.installError = &partialOperationError{ operation: operation, cause: err, guidance: guidance, } log.Error(m.installError.Error()) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "⚠ "+operation+" needs reconciliation") m.installOutput = append(m.installOutput, "Error: "+err.Error()) m.installOutput = append(m.installOutput, guidance) m.installOutput = append(m.installOutput, "") m.installOutput = append(m.installOutput, "Press Enter to return to the main menu") m.updateOutputViewport() } func (m *Model) resetToMainMenu() { m.state = StateMainMenu m.selectedMethod = "" m.selectedMethodID = "" m.installing = false m.installComplete = false m.installError = nil m.installSuccess = false m.installProgress = 0 m.installOutput = []string{} m.autoScroll = true m.outputViewportDirty = false m.lastOutputRefresh = time.Time{} m.currentCommand = "" m.installOperationType = OpTypeNone m.managerUpdateReady = false m.preparedManagerUpdate = nil m.downloadWritten = 0 m.downloadTotal = 0 m.clearOperationRuntime() m.selectedUninstall = nil m.selectedUpdateItem = nil m.selectedRestoreBackup = nil m.postInstallMessage = "" m.pendingMethodID = "" m.pendingAppName = "" m.selectedUIType = "" m.selectedUIApp = nil m.uiChangeMode = false } func tuiManagerUpdateProgress(phase version.SelfUpdatePhase) int { switch phase { case version.SelfUpdateChecking: return 2 case version.SelfUpdateDownloading: return 5 case version.SelfUpdateVerifying: return 80 case version.SelfUpdateCommitted: return 90 case version.SelfUpdateReadyToRestart: return 95 case version.SelfUpdateRestarting: return 99 case version.SelfUpdateRolledBack, version.SelfUpdateFailed: return 100 default: return 0 } } func (m *Model) handleConfigOptionsUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.configOptionsList, cmd = m.configOptionsList.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.state = StateMainMenu return m, nil case tea.KeyEnter: selected := m.configOptionsList.SelectedItem() if selected != nil { item := selected.(menuItem) switch item.id { case "reset-config": return m, m.runResetConfigCmd() case "reset-manager-data": m.resetManagerDataStatusMsg = "" m.state = StateResetManagerData return m, nil case "restore-config": if err := m.loadAndSetupBackupList(); err != nil { m.errorMessage = "Failed to load config backups: " + err.Error() m.state = StateError return m, nil } m.state = StateRestoreConfig return m, nil case "change-hwdec": m.setupHWAOptionsList() m.state = StateHWAOptions return m, nil case "modernz-options": m.setupModernZOptionsList() m.state = StateModernZOptions return m, nil case "uosc-options": m.setupUOSCOptionsList() m.state = StateUOSCOptions return m, nil case "install-path": m.installPathStatusMsg = "" m.setupInstallPathList() m.state = StateInstallPath return m, nil case "save-position-on-quit": m.setupSavePositionOnQuitList() m.state = StateSavePositionOnQuitEdit return m, nil case "screenshot-settings": m.setupScreenshotOptionsList() m.state = StateScreenshotOptions return m, nil case "file-associations": m.setupFileAssociationsList() m.state = StateFileAssociationsMenu return m, nil case "change-mpv-ui": if m.setupUIAppSelectList() == 0 { m.statusMessage = "No installed MPV app supports UI overlays" return m, nil } m.uiChangeMode = true m.selectedUIApp = nil m.state = StateUIAppSelect return m, nil } } } } return m, cmd } func (m *Model) handleHWAOptionsUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.hwaOptionsList, cmd = m.hwaOptionsList.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil case tea.KeyEnter: selected := m.hwaOptionsList.SelectedItem() if selected != nil { item := selected.(hwaOptionItem) m.selectedHWAOption = item.id return m, m.runApplyHWACmd() } } } return m, cmd } func (m *Model) handleSavePositionOnQuitUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.screenshotValueList, cmd = m.screenshotValueList.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil case tea.KeyEnter: selected := m.screenshotValueList.SelectedItem() if selected != nil { item := selected.(screenshotValueItem) // Apply the setting if err := config.SetConfigValue(constants.ConfigKeySavePositionOnQuit, []string{item.id}, false); err != nil { m.errorMessage = "Failed to apply save-position-on-quit setting: " + err.Error() m.state = StateError return m, nil } m.statusMessage = "Save Position on Quit set to: " + item.id m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil } } } return m, cmd } func (m *Model) handleHotkeysCategoryUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.hotkeysCategoryList, cmd = m.hotkeysCategoryList.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.setupHotkeysPresetList() m.state = StateHotkeysPreset m.statusMessage = "Keybinding Presets" return m, nil case tea.KeyEnter: selected := m.hotkeysCategoryList.SelectedItem() if selected != nil { item := selected.(hotkeyCategoryItem) m.setupHotkeysList(item.id) m.statusMessage = item.title m.state = StateHotkeys return m, nil } } } return m, cmd } func (m *Model) handleHotkeysUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.hotkeysList, cmd = m.hotkeysList.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.setupHotkeysCategoryList() m.state = StateHotkeysCategory m.statusMessage = "Keyboard Shortcuts" return m, nil } } return m, cmd } func (m *Model) handleHotkeysPresetUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.hotkeysPresetList, cmd = m.hotkeysPresetList.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.state = StateMainMenu m.statusMessage = "Main Menu" return m, nil case tea.KeyEnter: if m.presetApplyInFlight { return m, nil } selected := m.hotkeysPresetList.SelectedItem() if selected != nil { item := selected.(hotkeyPresetItem) if item.id == "view-all" { // Go to category list m.setupHotkeysCategoryList() m.state = StateHotkeysCategory m.statusMessage = "Keyboard Shortcuts" return m, nil } if item.id == "edit-bindings" { // Go to the current bindings editor m.setupHotkeysBindingsList() m.state = StateHotkeysBindings m.statusMessage = "Current Bindings" return m, nil } // Apply the selected preset m.presetApplyInFlight = true m.presetStatusMessage = "Applying preset…" return m, m.runApplyPresetCmd(item.id) } } } return m, cmd } func (m *Model) handleFileAssociationsMenuUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.fileAssociationsList, cmd = m.fileAssociationsList.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil case tea.KeyEnter: selected := m.fileAssociationsList.SelectedItem() if selected != nil { item := selected.(menuItem) switch item.id { case "install-associations": return m, m.runInstallAssociationsCmd() case "remove-associations": return m, m.runRemoveAssociationsCmd() } } } } return m, cmd } func (m *Model) handleScreenshotSettingsUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEnter: selected := m.screenshotOptionsList.SelectedItem() if selected != nil { if option, ok := selected.(screenshotOptionItem); ok { m.selectedScreenshotOption = option.id // Set up editing interface based on option type switch option.id { case "screenshot-format": m.setupScreenshotFormatList() m.state = StateScreenshotFormatEdit case "screenshot-tag-colorspace": m.setupScreenshotYesNoList("tag-colorspace", "Tag Colorspace in Filename") m.state = StateScreenshotTagColorspaceEdit case "screenshot-high-bit-depth": m.setupScreenshotYesNoList("high-bit-depth", "High Bit Depth") m.state = StateScreenshotHighBitDepthEdit case "screenshot-template": // Initialize textinput with current value currentVal := getCurrentScreenshotValue("template") if currentVal == "Not set" { currentVal = "mpv-shot%n" } ti := textinput.New() ti.Placeholder = "Enter filename template..." ti.SetValue(currentVal) ti.CharLimit = 156 ti.SetWidth(50) ti.Focus() m.screenshotTextInput = ti m.state = StateScreenshotTemplateEdit case "screenshot-directory": // Initialize textinput with current value currentVal := getCurrentScreenshotValue("directory") if currentVal == "Not set" { currentVal = "" } ti := textinput.New() ti.Placeholder = "Enter directory path..." ti.SetValue(currentVal) ti.CharLimit = 256 ti.SetWidth(50) ti.Focus() m.screenshotTextInput = ti m.state = StateScreenshotDirectoryEdit case "screenshot-jpeg-quality": values := make([]string, 101) for i := 0; i <= 100; i++ { values[i] = fmt.Sprintf("%d", i) } m.setupScreenshotNumericList("jpeg-quality", "JPEG Quality (0-100)", values) m.state = StateScreenshotJpegQualityEdit case "screenshot-png-compression": values := make([]string, 10) for i := 0; i <= 9; i++ { values[i] = fmt.Sprintf("%d", i) } m.setupScreenshotNumericList("png-compression", "PNG Compression (0-9)", values) m.state = StateScreenshotPngCompressionEdit case "screenshot-webp-lossless": m.setupScreenshotYesNoList("webp-lossless", "WebP Lossless") m.state = StateScreenshotWebpLosslessEdit case "screenshot-webp-quality": values := make([]string, 101) for i := 0; i <= 100; i++ { values[i] = fmt.Sprintf("%d", i) } m.setupScreenshotNumericList("webp-quality", "WebP Quality (0-100)", values) m.state = StateScreenshotWebpQualityEdit case "screenshot-jxl-distance": values := []string{"0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "1.0", "1.5", "2.0", "2.5", "3.0", "4.0", "5.0", "10.0", "15.0", "20.0", "25.0"} m.setupScreenshotNumericList("jxl-distance", "JPEG XL Distance", values) m.state = StateScreenshotJxlDistanceEdit case "screenshot-avif-encoder": m.setupScreenshotEncoderList() m.state = StateScreenshotAvifEncoderEdit case "screenshot-avif-pixfmt": m.setupScreenshotPixelFormatList() m.state = StateScreenshotAvifPixfmtEdit case "screenshot-avif-opts": // Initialize textinput with current value currentVal := getCurrentScreenshotValue("avif-opts") if currentVal == "Not set" { currentVal = "usage=allintra,crf=0,cpu-used=8" } ti := textinput.New() ti.Placeholder = "Enter encoder options..." ti.SetValue(currentVal) ti.CharLimit = 256 ti.SetWidth(50) ti.Focus() m.screenshotTextInput = ti m.state = StateScreenshotAvifOptsEdit } return m, nil } } case tea.KeyEsc: m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil } } m.screenshotOptionsList, cmd = m.screenshotOptionsList.Update(msg) return m, cmd } // Generic handler for screenshot value selection (dropdown lists) func (m *Model) handleScreenshotValueSelection(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.screenshotValueList, cmd = m.screenshotValueList.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEnter: selected := m.screenshotValueList.SelectedItem() if selected != nil { if val, ok := selected.(screenshotValueItem); ok { // Apply selected value key := getScreenshotConfigKey(m.selectedScreenshotOption) if err := config.SetConfigValue(key, []string{val.id}, false); err != nil { m.errorMessage = fmt.Sprintf("Failed to set %s: %v", key, err) m.state = StateError return m, nil } // Return to screenshot options list and refresh m.setupScreenshotOptionsList() m.state = StateScreenshotOptions } } case tea.KeyEsc: m.setupScreenshotOptionsList() m.state = StateScreenshotOptions return m, nil } } return m, cmd } // Handler for screenshot text input fields func (m *Model) handleScreenshotTextInput(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd var tiCmd tea.Cmd m.screenshotTextInput, tiCmd = m.screenshotTextInput.Update(msg) switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEnter: // Apply the entered value value := m.screenshotTextInput.Value() key := getScreenshotConfigKey(m.selectedScreenshotOption) if err := config.ValidateMPVConfigValue(key, value); err != nil { m.errorMessage = "Invalid value: " + err.Error() return m, nil } if err := config.SetConfigValue(key, []string{value}, false); err != nil { m.errorMessage = fmt.Sprintf("Failed to set %s: %v", key, err) m.state = StateError return m, nil } // Return to screenshot options list and refresh m.errorMessage = "" m.setupScreenshotOptionsList() m.state = StateScreenshotOptions case tea.KeyEsc: m.setupScreenshotOptionsList() m.state = StateScreenshotOptions return m, nil } } return m, tea.Batch(cmd, tiCmd) } func normalizePostInstallMessage(err error) (installer.PostInstallMessage, bool) { if err == nil { return installer.PostInstallMessage{}, false } var message installer.PostInstallMessage if errors.As(err, &message) { return message, true } var messagePointer *installer.PostInstallMessage if errors.As(err, &messagePointer) && messagePointer != nil { return *messagePointer, true } return installer.PostInstallMessage{}, false } func (m *Model) runResetConfigCmd() tea.Cmd { if m.operationActive { return nil } m.statusMessage = "Resetting MPV config to recommended..." m.installing = true m.state = StateInstalling m.installOperationType = OpTypeConfig outputChan := make(chan string) installDone := make(chan error) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, func(_ context.Context, cr *installer.CommandRunner) error { return m.installer.InstallMPVConfigWithOutput(cr) }) return m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard, }) } func (m *Model) runApplyHWACmd() tea.Cmd { if m.operationActive { return nil } m.statusMessage = "Applying hardware acceleration setting..." m.installing = true m.state = StateInstalling m.installOperationType = OpTypeConfig outputChan := make(chan string) installDone := make(chan error) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, func(ctx context.Context, _ *installer.CommandRunner) error { if err := ctx.Err(); err != nil { return err } outputChan <- fmt.Sprintf("Setting hwdec=%s", m.selectedHWAOption) if err := m.applyHWAOption(m.selectedHWAOption); err != nil { outputChan <- "Failed to apply hardware acceleration: " + err.Error() return err } outputChan <- fmt.Sprintf("Hardware acceleration set to: %s", m.selectedHWAOption) outputChan <- "βœ“ Configuration updated successfully" return nil }) return m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard, }) } func (m *Model) runRestoreConfigCmd() tea.Cmd { if m.operationActive { return nil } outputChan := make(chan string) installDone := make(chan error) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, func(_ context.Context, cr *installer.CommandRunner) error { var err error if m.selectedRestoreBackup != nil { err = m.installer.RestoreConfigWithOutput(cr, m.selectedRestoreBackup.BackupPath) } return err }) return m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard, }) } func (m *Model) runInstallAssociationsCmd() tea.Cmd { if m.operationActive { return nil } m.statusMessage = "Installing File Associations" m.installing = true m.state = StateInstalling m.installOperationType = OpTypeConfig outputChan := make(chan string) installDone := make(chan error) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, func(_ context.Context, cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(m.platformInfo, m.installer) return handler.SetupFileAssociationsWithOutput(cr) }) return m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard, }) } func (m *Model) runRemoveAssociationsCmd() tea.Cmd { if m.operationActive { return nil } m.statusMessage = "Removing File Associations" m.installing = true m.state = StateInstalling m.installOperationType = OpTypeConfig outputChan := make(chan string) installDone := make(chan error) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, func(_ context.Context, cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(m.platformInfo, m.installer) return handler.RemoveFileAssociationsWithOutput(cr) }) return m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard, }) } func (m *Model) runCreateWebUIShortcutCmd() tea.Cmd { if m.operationActive { return nil } m.statusMessage = "Creating Web UI Shortcut" m.installing = true m.state = StateInstalling m.installOperationType = OpTypeConfig outputChan := make(chan string) installDone := make(chan error) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, func(_ context.Context, cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(m.platformInfo, m.installer) return handler.CreateWebUIShortcutWithOutput(cr) }) return m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard, }) } func (m *Model) loadAndSetupBackupList() error { backups, err := version.ListConfigBackups() if err != nil { return err } items := make([]list.Item, 0, len(backups)) for _, backup := range backups { items = append(items, configBackupItem{backup: &backup}) } m.restoreConfigList = m.createStyledList("Select Backup to Restore", items) return nil } // handleUISelectUpdate handles the UI selection state func (m *Model) handleUIAppSelectUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd m.uiAppList, cmd = m.uiAppList.Update(msg) if keyMsg, ok := msg.(tea.KeyPressMsg); ok { switch keyCode(keyMsg) { case tea.KeyEnter: selected, ok := m.uiAppList.SelectedItem().(uninstallAppItem) if !ok || selected.app == nil { return m, cmd } app := *selected.app m.selectedUIApp = &app m.pendingMethodID = app.InstallMethod m.pendingAppName = app.AppName m.setupUISelectList() m.state = StateUISelect return m, nil case tea.KeyEsc: m.selectedUIApp = nil m.uiChangeMode = false m.setupConfigOptionsList() m.state = StateConfigOptions return m, nil } } return m, cmd } func (m *Model) handleUISelectUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEnter: selected := m.uiSelectList.SelectedItem() if selected != nil { uiItem := selected.(uiOptionItem) m.selectedUIType = uiItem.id if m.uiChangeMode { if m.selectedUIApp == nil { m.errorMessage = "No installed MPV app was selected for the UI change" m.state = StateError return m, nil } m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false m.installOperationType = OpTypeUIChange m.selectedMethodID = m.selectedUIApp.InstallMethod m.selectedMethod = m.selectedUIApp.AppName return m, m.executeUIChangeCmd(*m.selectedUIApp, m.selectedUIType, m.installer) } m.state = StateInstalling m.installProgress = 0 m.installing = true m.installComplete = false m.installOperationType = OpTypeInstall // Set the selected method for proper tracking m.selectedMethodID = m.pendingMethodID m.selectedMethod = getAppNameForMethod(m.pendingMethodID) installDir := m.getInstallPath() return m, m.executeInstallCmdWithUI(m.pendingMethodID, installDir, m.selectedUIType, m.platformInfo, m.installer) } case tea.KeyEsc: if m.uiChangeMode { m.state = StateUIAppSelect return m, nil } m.state = StateInstallMenu m.pendingMethodID = "" m.pendingAppName = "" return m, nil } } m.uiSelectList, cmd = m.uiSelectList.Update(msg) return m, cmd }