package tui import ( "context" "os" "sort" "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/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/platform" "gitgud.io/mike/mpv-manager/pkg/version" ) func (m *Model) Init() tea.Cmd { commands := make([]tea.Cmd, 0, 3) if m.operationActive { commands = append(commands, m.nextOperationStreamCmd()) } if m.releaseInfoContextLoader != nil { commands = append(commands, m.observationCmd(func(ctx context.Context) tea.Msg { return loadReleaseInfoContextCmd(ctx, m.releaseInfoContextLoader)() })) } else if m.releaseInfoLoader != nil { commands = append(commands, m.observationCmd(func(context.Context) tea.Msg { return loadReleaseInfoCmd(m.releaseInfoLoader)() })) } else { commands = append(commands, m.checkForUpdateCmd()) } if m.postUpdateJournal != "" { // Init commands are handed to the command loop immediately before the // initial view is rendered. The resulting message is not processed by // Update until after that render, giving us a real post-first-frame hook. commands = append(commands, func() tea.Msg { return postUpdateRenderReadyMsg{} }) } return tea.Batch(commands...) } func NewModel() *Model { platformInfo := PlatformInfo{ AppVersion: "", OSType: "Detecting...", Arch: "Detecting...", Distro: "", DistroFamily: "", CPU: "Detecting...", GPU: "Detecting...", HWA: "auto", } outputViewport := viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)) outputViewport.Style = lipgloss.NewStyle() logsViewport := viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)) jobDetailViewport := viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)) delegate := list.NewDefaultDelegate() delegate.ShowDescription = true delegate.Styles.SelectedTitle = selectedItemStyle delegate.Styles.SelectedDesc = infoStyle // Initialize textinput for screenshot string options ti := textinput.New() ti.Placeholder = "Enter value..." ti.CharLimit = 156 ti.SetWidth(50) model := &Model{ state: StateMainMenu, observations: &tuiObservations{}, platform: platformInfo, menuList: list.New(nil, list.NewDefaultDelegate(), 0, 0), installMenuList: list.New(nil, list.NewDefaultDelegate(), 0, 0), uninstallList: list.New(nil, delegate, 0, 0), updateList: list.New(nil, list.NewDefaultDelegate(), 0, 0), restoreConfigList: list.New(nil, list.NewDefaultDelegate(), 0, 0), outputViewport: outputViewport, logsViewport: logsViewport, jobDetailViewport: jobDetailViewport, statusMessage: "Initializing...", installOutput: []string{}, autoScroll: true, majorLangList: list.New(nil, list.NewDefaultDelegate(), 0, 0), regionLangList: list.New(nil, list.NewDefaultDelegate(), 0, 0), selectedLangList: list.New(nil, list.NewDefaultDelegate(), 0, 0), langPreferenceList: list.New(nil, list.NewDefaultDelegate(), 0, 0), configOptionsList: list.New(nil, list.NewDefaultDelegate(), 0, 0), hwaOptionsList: list.New(nil, list.NewDefaultDelegate(), 0, 0), webUIList: list.New(nil, list.NewDefaultDelegate(), 0, 0), fileAssociationsList: list.New(nil, list.NewDefaultDelegate(), 0, 0), hotkeysList: list.New(nil, list.NewDefaultDelegate(), 0, 0), hotkeysCategoryList: list.New(nil, list.NewDefaultDelegate(), 0, 0), hotkeysPresetList: list.New(nil, list.NewDefaultDelegate(), 0, 0), screenshotOptionsList: list.New(nil, list.NewDefaultDelegate(), 0, 0), uiSelectList: list.New(nil, list.NewDefaultDelegate(), 0, 0), uiAppList: list.New(nil, delegate, 0, 0), hotkeysBindingsList: list.New(nil, list.NewDefaultDelegate(), 0, 0), modernzList: list.New(nil, list.NewDefaultDelegate(), 0, 0), uoscList: list.New(nil, list.NewDefaultDelegate(), 0, 0), installPathList: list.New(nil, list.NewDefaultDelegate(), 0, 0), jobHistoryList: list.New(nil, list.NewDefaultDelegate(), 0, 0), installerCheckType: "", managerExecutable: os.Executable, prepareManagerUpdate: version.PrepareSelfUpdateFromCheckContext, launchManagerUpdate: func(prepared *version.PreparedSelfUpdate, mode string) error { return prepared.LaunchUpdateHelper(mode) }, acknowledgeSelfUpdate: version.AcknowledgeSelfUpdate, screenshotTextInput: ti, screenshotValueList: list.New(nil, list.NewDefaultDelegate(), 0, 0), } model.refreshUIConfigStatus() return model } // SetPostUpdateHealthAcknowledgement defers the updater acknowledgement until // Bubble Tea has initialized its terminal and completed the first render. func (m *Model) SetPostUpdateHealthAcknowledgement(journalPath string) { m.postUpdateJournal = journalPath } func (m *Model) SetPlatform(info PlatformInfo) { m.platform = info } func NewMenuItemsFromInstaller(methods []installer.InstallMethod) []menuItem { items := make([]menuItem, len(methods)) for i, method := range methods { items[i] = menuItem{ id: method.ID, title: method.Name, description: method.Description, recommended: method.Recommended, methodID: method.ID, // Set methodID for badge determination } } return items } func NewMainMenuItems(installedAppsCount int) []menuItem { inPath := isInstallMPVInPATH() var pathTitle, pathDesc string if inPath { pathTitle = "πŸ—‘οΈ Remove mpv-manager from PATH" pathDesc = "Remove mpv-manager from your system PATH" } else { pathTitle = "πŸ“₯ Add mpv-manager to your PATH" pathDesc = "Add mpv-manager to your system PATH for easy access" } if installedAppsCount == 0 { return []menuItem{ { id: "mpv-manager", title: "πŸ“₯ Install an MPV Player", description: "See options for installing MPV and MPV-based media players", recommended: false, }, { id: "updates", title: "πŸ”„ Updates", description: "Update installer or load config backups", recommended: false, }, { id: "set-language-preferences", title: "πŸ’¬ Set Language Preferences", description: "Configure audio and subtitle language priority for MPV", recommended: false, }, { id: "config-options", title: "βš™οΈ MPV Config Options", description: "Configure MPV player settings and hardware acceleration", recommended: false, }, { id: "hotkeys", title: "⌨️ Keyboard Shortcuts", description: "View all MPV keyboard shortcuts", recommended: false, }, { id: "web-ui", title: "🌐 Web UI", description: "Create desktop shortcuts for Web UI mode", recommended: false, }, { id: "job-history", title: "πŸ“œ Installation History", description: "View past install, update, and uninstall jobs", recommended: false, }, { id: "view-logs", title: "πŸ“„ View Logs", description: "View the mpv-manager log file", recommended: false, }, { id: "install-to-path", title: pathTitle, description: pathDesc, recommended: false, }, } } menuItems := []menuItem{ { id: "updates", title: "πŸ”„ Updates", description: "Update MPV installer or load config backups", recommended: false, }, { id: "set-language-preferences", title: "πŸ’¬ Set Language Preferences", description: "Configure audio and subtitle language priority for MPV", recommended: false, }, { id: "config-options", title: "βš™οΈ MPV Config Options", description: "Configure MPV player settings and hardware acceleration", recommended: false, }, { id: "hotkeys", title: "⌨️ Keyboard Shortcuts", description: "View all MPV keyboard shortcuts", recommended: false, }, { id: "mpv-manager", title: "πŸ“₯ Install an MPV Player", description: "See options for installing MPV and MPV-based media players", recommended: false, }, { id: "unmpv-manager", title: "πŸ—‘οΈ Uninstall an MPV Player", description: "Remove MPV clients", recommended: false, }, { id: "web-ui", title: "🌐 Web UI", description: "Create desktop shortcuts for Web UI mode", recommended: false, }, { id: "job-history", title: "πŸ“œ Installation History", description: "View past install, update, and uninstall jobs", recommended: false, }, { id: "view-logs", title: "πŸ“„ View Logs", description: "View the mpv-manager log file", recommended: false, }, { id: "install-to-path", title: pathTitle, description: pathDesc, recommended: false, }, } return menuItems } func (m *Model) SetInstaller(inst *installer.Installer, p *platform.Platform) { m.installer = inst m.platformInfo = p } func (m *Model) SetMenuItems(items []menuItem, title string) { listItems := make([]list.Item, len(items)) for i, item := range items { listItems[i] = item } m.menuList = m.createStyledList(title, listItems) } func (m *Model) RefreshMainMenu() { m.refreshUIConfigStatus() apps := config.GetInstalledApps() menuItems := NewMainMenuItems(len(apps)) m.SetMenuItems(menuItems, "Main Menu") } func (m *Model) SetInstallMenuItems(items []menuItem, title string) { listItems := make([]list.Item, len(items)) for i, item := range items { listItems[i] = item } m.installMenuList = m.createStyledList(title, listItems) } func (m *Model) SetInstallerWithMethods(inst *installer.Installer, p *platform.Platform, methods []installer.InstallMethod) { m.installer = inst m.platformInfo = p m.methods = methods } // SetReleaseInfoLoader configures the bounded asynchronous manifest refresh // used after Bubble Tea has rendered its first frame. // Deprecated: use SetReleaseInfoLoaderContext so shutdown cancels requests. func (m *Model) SetReleaseInfoLoader(loader func() (*installer.ReleaseInfo, error)) { m.releaseInfoContextLoader = nil m.releaseInfoLoader = loader m.releaseInfoLoading = loader != nil if loader != nil { m.releaseInfoStatus = "Refreshing release information…" } } // SetReleaseInfoLoaderContext configures a manifest refresh whose requests // and retry delays belong to the TUI's lifetime. func (m *Model) SetReleaseInfoLoaderContext(loader func(context.Context) (*installer.ReleaseInfo, error)) { m.releaseInfoContextLoader = loader m.releaseInfoLoader = nil m.releaseInfoLoading = loader != nil if loader != nil { m.releaseInfoStatus = "Refreshing release information…" } } func (m *Model) setupInstallMenuList() { if len(m.methods) == 0 { m.statusMessage = "No installation methods available" return } items := NewMenuItemsFromInstaller(m.methods) // Sort items: MPV methods (Best Playback) first, then GUI methods (Easy UI) sort.Slice(items, func(i, j int) bool { iIsMPV := constants.SupportsMPVOverlay(items[i].methodID) jIsMPV := constants.SupportsMPVOverlay(items[j].methodID) // MPV methods come first if iIsMPV && !jIsMPV { return true } if !iIsMPV && jIsMPV { return false } // Within same category, sort by name if items[i].title != items[j].title { return items[i].title < items[j].title } // Same name, sort by ID (e.g., package before flatpak) return items[i].id < items[j].id }) listItems := make([]list.Item, len(items)) for i, item := range items { listItems[i] = item } m.installMenuList = m.createStyledList("Select Installation Method", listItems) } func (m *Model) SetState(state State) { m.state = state } func (m *Model) SetStatusMessage(msg string) { m.statusMessage = msg } func (m *Model) SetErrorMessage(msg string) { m.errorMessage = msg } func (m *Model) SetProgress(progress int) { m.installProgress = progress } func (m *Model) Installing() bool { return m.installing } func (m *Model) GetSelectedMethodID() string { return m.selectedMethodID } func (m *Model) GetSelectedMethod() string { return m.selectedMethod } func (m *Model) updateOutputViewport() { m.outputViewport.SetContentLines(sanitizedOutputLines(m.installOutput)) } func (m *Model) createStyledList(title string, items []list.Item) list.Model { delegate := list.NewDefaultDelegate() delegate.ShowDescription = true delegate.Styles.SelectedTitle = selectedItemStyle delegate.Styles.SelectedDesc = infoStyle l := list.New(items, delegate, 0, 0) l.Title = title l.Styles.Title = titleStyle l.Styles.Filter.Focused.Prompt = infoStyle l.Styles.Filter.Blurred.Prompt = infoStyle l.Styles.Filter.Cursor.Color = selectedItemStyle.GetForeground() if m.termWidth > 0 && m.termHeight > 0 { l.SetWidth(positiveSize(m.termWidth - 10)) l.SetHeight(positiveSize(m.termHeight - 15)) } return l } func (m *Model) setupWebUIList() { shortcutState := "disabled" if config.GetCreateManagerShortcut() { shortcutState = "enabled" } items := []list.Item{ menuItem{ id: "create-webui-shortcut", title: "🌐 Create Web UI Shortcut", description: "Create a shortcut to launch MPV Installer in Web UI mode", recommended: false, }, menuItem{ id: "toggle-webui-shortcut", title: "πŸ” Toggle Automatic Shortcut Creation", description: "Create a Web UI desktop shortcut on startup (currently " + shortcutState + ")", recommended: false, }, } m.webUIList = m.createStyledList("Web UI Options", items) }