pax_global_header00006660000000000000000000000064145431165100014512gustar00rootroot0000000000000052 comment=a9ace891b3708a0cc3a07312272757d81e3aa11a invidtui-0.3.7/000077500000000000000000000000001454311651000133545ustar00rootroot00000000000000invidtui-0.3.7/.gitignore000066400000000000000000000000061454311651000153400ustar00rootroot00000000000000dist/ invidtui-0.3.7/.goreleaser.yml000066400000000000000000000016261454311651000163120ustar00rootroot00000000000000project_name: invidtui builds: - env: - CGO_ENABLED=0 - GO111MODULE=on - GOPROXY=https://proxy.golang.org ldflags: - -s -w -X github.com/darkhz/invidtui/cmd.Version={{.Version}}@{{.ShortCommit}} goos: - linux - darwin - windows goarch: - arm - 386 - arm64 - amd64 goarm: - 5 - 6 - 7 ignore: - goos: windows goarch: arm64 - goos: windows goarch: arm archives: - id: foo name_template: >- {{ .ProjectName }}_{{ .Version }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else if eq .Arch "arm" }}{{ .Arch }}v{{ .Arm }} {{- else }}{{ .Arch }}{{ end }} files: - LICENSE checksum: name_template: 'checksums.txt' snapshot: name_template: "{{ .Tag }}-next" changelog: skip: true invidtui-0.3.7/LICENSE000066400000000000000000000020501454311651000143560ustar00rootroot00000000000000MIT License Copyright (c) 2021 darkhz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. invidtui-0.3.7/README.md000066400000000000000000000020221454311651000146270ustar00rootroot00000000000000[![Go Report Card](https://goreportcard.com/badge/github.com/darkhz/invidtui)](https://goreportcard.com/report/github.com/darkhz/invidtui) # invidtui [![youtube](https://img.youtube.com/vi/Zw7JDcu92FE/3.jpg)](https://youtube.com/watch?v=Zw7JDcu92FE) invidtui is an invidious client, which fetches data from invidious instances and displays a user interface in the terminal(TUI), and allows for selecting and playing Youtube audio and video. Currently, it is tested on Linux and Windows, and it should work on MacOS. ## Features - Play audio or video - Control the video resolution - Ability to open, view, edit and save m3u8 playlists - Automatically queries the invidious API and selects the best instance - Search for and browse videos, playlists and channels, with history support - Authentication with invidious and management of user feed, playlists and subscriptions - Download video and/or audio ## Documentation Refer to the documentation [here](https://darkhz.github.io/invidtui/). The wiki is now out-of-date. ## License MIT invidtui-0.3.7/client/000077500000000000000000000000001454311651000146325ustar00rootroot00000000000000invidtui-0.3.7/client/auth.go000066400000000000000000000046551454311651000161340ustar00rootroot00000000000000package client import ( "fmt" "net/url" "sync" "github.com/darkhz/invidtui/utils" ) // Auth stores information about instances and the user's authentication // tokens associated with it. type Auth struct { store map[string]string mutex sync.Mutex } // Credential stores an instance and the user's authentication token. type Credential struct { Instance string `json:"instance"` Token string `json:"token"` } // Scopes lists the user token's scopes. const Scopes = "GET:playlists*,GET:subscriptions*,GET:feed*,GET:notifications*,GET:tokens*" var auth Auth // SetAuthCredentials sets the authentication credentials. func SetAuthCredentials(credentials []Credential) { auth.store = make(map[string]string) for _, credential := range credentials { auth.store[credential.Instance] = credential.Token } } // GetAuthCredentials returns the authentication credentials. func GetAuthCredentials() []Credential { var creds []Credential for instance, token := range auth.store { creds = append(creds, Credential{instance, token}) } return creds } // AddAuth adds and stores an instance and token credential. func AddAuth(instance, token string) { auth.mutex.Lock() defer auth.mutex.Unlock() if instance == "" || token == "" { return } instanceURI, _ := url.Parse(instance) if instanceURI.Scheme == "" { instanceURI.Scheme = "https" } auth.store[instanceURI.String()] = token } // AddCurrentAuth adds and stores an instance and token credential // for the selected instance. func AddCurrentAuth(token string) { auth.mutex.Lock() defer auth.mutex.Unlock() auth.store[Instance()] = token } // Token returns the stored token for the selected instance. func Token() string { auth.mutex.Lock() defer auth.mutex.Unlock() return auth.store[Instance()] } // AuthLink returns an authorization link. func AuthLink(instance ...string) string { if instance == nil { instance = append(instance, utils.GetHostname(Instance())) } return fmt.Sprintf("https://%s/authorize_token?scopes=%s", instance[0], Scopes, ) } // IsTokenValid tests the validity of the given token. func IsTokenValid(token string) bool { _, err := Fetch(Ctx(), "auth/tokens", token) return err == nil } // CurrentTokenValid tests the validity of the stored token. func CurrentTokenValid() bool { return IsTokenValid(Token()) } // IsAuthInstance checks whether the selected instance // has a stored token. func IsAuthInstance() bool { return Token() != "" } invidtui-0.3.7/client/client.go000066400000000000000000000141521454311651000164420ustar00rootroot00000000000000package client import ( "bytes" "context" "fmt" "io" "net" "net/http" "net/url" "sync" "time" "github.com/darkhz/invidtui/resolver" "github.com/darkhz/invidtui/utils" ) const ( // API is the api endpoint. API = "/api/v1/" // InstanceData is the URL to retrieve available Invidious instances. InstanceData = "https://api.invidious.io/instances.json?sort_by=api,health" // UserAgent is the user agent for the client. UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36" ) // Client stores information about a client. type Client struct { uri *url.URL rctx, sctx context.Context rcancel, scancel context.CancelFunc mutex sync.Mutex *http.Client } var client Client // Init intitializes the client. func Init() { client = Client{} client.Client = &http.Client{ Timeout: 10 * time.Minute, Transport: &http.Transport{ TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 20 * time.Second, DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 10 * time.Second, }).DialContext, }, } client.rctx, client.rcancel = context.WithCancel(context.Background()) client.sctx, client.scancel = context.WithCancel(context.Background()) } // Host returns the client's host. func Host() string { if client.uri == nil { return "" } return client.uri.Scheme + "://" + client.uri.Hostname() } // SetHost sets the client's host. func SetHost(host string) *url.URL { client.mutex.Lock() defer client.mutex.Unlock() client.uri, _ = url.Parse(host) if client.uri.Scheme == "" { client.uri.Scheme = "https" client.uri, _ = url.Parse(client.uri.String()) } return client.uri } // Get send a GET request to the host and returns a response func Get(ctx context.Context, param string, token ...string) (*http.Response, error) { res, err := request(ctx, http.MethodGet, param, nil, token...) if err != nil { return nil, err } return checkStatusCode(res, http.StatusOK) } // Post send a POST request to the host and returns a response. func Post(ctx context.Context, param, body string, token ...string) (*http.Response, error) { res, err := request(ctx, http.MethodPost, param, bytes.NewBuffer([]byte(body)), token...) if err != nil { return nil, err } return checkStatusCode(res, 201, 204) } // Delete send a DELETE request to the host and returns a response. func Delete(ctx context.Context, param string, token ...string) (*http.Response, error) { res, err := request(ctx, http.MethodDelete, param, nil, token...) if err != nil { return nil, err } return checkStatusCode(res, 204) } // Patch send a PATCH request to the host and returns a response. func Patch(ctx context.Context, param, body string, token ...string) (*http.Response, error) { res, err := request(ctx, http.MethodPatch, param, bytes.NewBuffer([]byte(body)), token...) if err != nil { return nil, err } if res.StatusCode != 204 { return nil, fmt.Errorf("HTTP request returned %d", res.StatusCode) } return res, err } // Fetch sends a GET request to the API endpoint and returns a response. func Fetch(ctx context.Context, param string, token ...string) (*http.Response, error) { return Get(ctx, API+param, token...) } // Send sends a POST request to the API endpoint and returns a response. func Send(param, body string, token ...string) (*http.Response, error) { SendCancel() return Post(SendCtx(), API+param, body, token...) } // Remove sends a DELETE request to the API endpoint and returns a response. func Remove(param string, token ...string) (*http.Response, error) { SendCancel() return Delete(SendCtx(), API+param, token...) } // Modify sends a PATCH request to the API endpoint and returns a response. func Modify(param, body string, token ...string) (*http.Response, error) { SendCancel() return Patch(SendCtx(), API+param, body, token...) } // Ctx returns the client's current context. func Ctx() context.Context { return client.rctx } // Cancel cancels the client's context. func Cancel() { if client.rctx != nil { client.rcancel() } client.rctx, client.rcancel = context.WithCancel(context.Background()) } // SendCtx returns the client's send context. func SendCtx() context.Context { return client.sctx } // SendCancel cancels the client's send context. func SendCancel() { if client.sctx != nil { client.scancel() } client.sctx, client.scancel = context.WithCancel(context.Background()) } // request sends a HTTP request to the URL and returns a response. func request(ctx context.Context, method, param string, body io.Reader, token ...string) (*http.Response, error) { if client.uri == nil { return nil, fmt.Errorf("Client: Not initialized") } req, err := http.NewRequestWithContext(ctx, method, Host()+param, body) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgent) if method == http.MethodPost || method == http.MethodPatch { req.Header.Set("Content-Type", "application/json") } if token != nil { if utils.IsValidJSON(token[0]) { req.Header.Set("Authorization", "Bearer "+token[0]) } else { req.AddCookie( &http.Cookie{ Name: "SID", Value: utils.SanitizeCookie(token[0]), }, ) } } res, err := client.Do(req) if err != nil { return nil, netError(err) } return res, nil } // checkStatusCode checks and returns an error if the codes don't match the response's status code. func checkStatusCode(res *http.Response, codes ...int) (*http.Response, error) { var checked int for _, code := range codes { if res.StatusCode != code { checked++ } } if checked == len(codes) { var responseError struct { Error string `json:"error"` } message := "API request returned %d" if err := resolver.DecodeJSONReader(res.Body, &responseError); err == nil && responseError.Error != "" { message += ": " + responseError.Error } return nil, fmt.Errorf(message, res.StatusCode) } return res, nil } // netError returns messages for common network errors. func netError(err error) error { if err, ok := err.(net.Error); ok { switch { case err.Timeout(): return fmt.Errorf("Client: Connection has timed out") } } return err } invidtui-0.3.7/client/instances.go000066400000000000000000000036261454311651000171570ustar00rootroot00000000000000package client import ( "fmt" "net/http" "net/url" "strings" "github.com/darkhz/invidtui/resolver" ) // Instance returns the client's current instance. func Instance() string { return Host() } // GetInstances returns a list of instances. func GetInstances() ([]string, error) { var instances [][]interface{} var list []string host := Instance() dataURI := SetHost(InstanceData) res, err := Get(Ctx(), fmt.Sprintf("%s?%s", dataURI.Path, dataURI.RawQuery)) if err != nil { return nil, err } err = resolver.DecodeJSONReader(res.Body, &instances) if err != nil { return nil, err } for _, instance := range instances { if inst, ok := instance[0].(string); ok { if !strings.Contains(inst, ".onion") && !strings.Contains(inst, ".i2p") { list = append(list, inst) } } } SetHost(host) return list, nil } // CheckInstance returns if the provided instance is valid. func CheckInstance(host string) (string, error) { if strings.Contains(host, ".onion") || strings.Contains(host, ".i2p") { return "", fmt.Errorf("Client: Invalid URL") } SetHost(host) host = Instance() res, err := request(Ctx(), http.MethodHead, API+"search", nil) if err == nil && res.StatusCode == 200 { return host, nil } return "", fmt.Errorf("Client: Cannot select instance") } // GetBestInstance determines and returns the best instance. func GetBestInstance(custom string) (string, error) { var bestInstance string if custom != "" { if uri, err := url.Parse(custom); err == nil { host := uri.Hostname() if host != "" { custom = host } } return CheckInstance(custom) } instances, err := GetInstances() if err != nil { return "", err } for _, instance := range instances { if inst, err := CheckInstance(instance); err == nil { bestInstance = inst break } } if bestInstance == "" { return "", fmt.Errorf("Client: Cannot find an instance") } return bestInstance, nil } invidtui-0.3.7/cmd/000077500000000000000000000000001454311651000141175ustar00rootroot00000000000000invidtui-0.3.7/cmd/cmd.go000066400000000000000000000044231454311651000152140ustar00rootroot00000000000000package cmd import ( "fmt" "strconv" "strings" "github.com/darkhz/invidtui/client" mp "github.com/darkhz/invidtui/mediaplayer" ) // Version stores the version information. var Version string // Init parses the command-line parameters and initializes the application. func Init() { printer.setup() config.setup() parse() printVersion() generate() client.Init() printInstances() check() loadInstance() loadPlayer() printer.Stop() } // loadInstance selects an instance. func loadInstance() { if IsOptionEnabled("instance-validated") { return } customInstance := GetOptionValue("force-instance") msg := "Selecting an instance" if customInstance != "" { msg = "Checking " + customInstance } printer.Print(msg) instance, err := client.GetBestInstance(customInstance) if err != nil { printer.Error(err.Error()) } client.SetHost(instance) } // loadPlayer loads the media player. func loadPlayer() { printer.Print("Starting player") socketpath, err := GetPath("socket") if err != nil { printer.Error(err.Error()) } err = mp.Init( "mpv", GetOptionValue("mpv-path"), GetOptionValue("ytdl-path"), GetOptionValue("num-retries"), client.UserAgent, socketpath, ) if err != nil { printer.Error(err.Error()) } } // printVersion prints the version information. func printVersion() { if !IsOptionEnabled("version") { return } text := "InvidTUI v%s" versionInfo := strings.Split(Version, "@") if len(versionInfo) < 2 { printer.Print(fmt.Sprintf(text, versionInfo), 0) } text += " (%s)" printer.Print(fmt.Sprintf(text, versionInfo[0], versionInfo[1]), 0) } // printInstances prints a list of instances. func printInstances() { var list string if !IsOptionEnabled("show-instances") { return } printer.Print("Retrieving instances") instances, err := client.GetInstances() if err != nil { printer.Error(fmt.Sprintf("Error retrieving instances: %s", err.Error())) } list += "Instances list:\n" list += strings.Repeat("-", len(list)) + "\n" for i, instance := range instances { list += strconv.Itoa(i+1) + ": " + instance + "\n" } printer.Print(list, 0) } // generate generates the configuration. func generate() { if !IsOptionEnabled("generate") { return } generateConfig() printer.Print("Configuration is generated", 0) } invidtui-0.3.7/cmd/config.go000066400000000000000000000103601454311651000157130ustar00rootroot00000000000000package cmd import ( "fmt" "os" "path/filepath" "strings" "sync" "github.com/darkhz/invidtui/platform" "github.com/hjson/hjson-go/v4" "github.com/knadh/koanf/v2" ) // Config describes the configuration for the app. type Config struct { path string mutex sync.Mutex *koanf.Koanf } var config Config // Init sets up the configuration. func (c *Config) setup() { var configExists bool c.Koanf = koanf.New(".") homedir, err := os.UserHomeDir() if err != nil { printer.Error(err.Error()) } dirs := []string{".config/invidtui", ".invidtui"} for i, dir := range dirs { p := filepath.Join(homedir, dir) dirs[i] = p if _, err := os.Stat(p); err == nil { c.path = p return } if i > 0 { continue } if _, err := os.Stat(filepath.Clean(filepath.Dir(p))); err == nil { configExists = true } } if c.path == "" { var pos int var err error if configExists { err = os.Mkdir(dirs[0], 0700) } else { pos = 1 err = os.Mkdir(dirs[1], 0700) } if err != nil { printer.Error(err.Error()) } c.path = dirs[pos] } } // GetPath returns the full config path for the provided file type. func GetPath(ftype string, nocreate ...struct{}) (string, error) { var cfpath string if ftype == "socket" { socket := filepath.Join(config.path, "socket") cfpath = platform.Socket(socket) if _, err := os.Stat(socket); err == nil { if !IsOptionEnabled("close-instances") { return "", fmt.Errorf("Config: Socket exists at %s, is another instance running?", socket) } if err := os.Remove(socket); err != nil { return "", fmt.Errorf("Config: Cannot remove %s", socket) } } fd, err := os.OpenFile(socket, os.O_CREATE, os.ModeSocket) if err != nil { return "", fmt.Errorf("Config: Cannot create socket file at %s", socket) } fd.Close() return cfpath, nil } cfpath = filepath.Join(config.path, ftype) if nocreate != nil { _, err := os.Stat(cfpath) return cfpath, err } fd, err := os.OpenFile(cfpath, os.O_CREATE, os.ModePerm) if err != nil { return "", fmt.Errorf("Config: Cannot create %s file at %s", ftype, cfpath) } fd.Close() return cfpath, nil } // GetQueryParams returns the parameters for the search and play option types. func GetQueryParams(queryType string) (string, string, error) { config.mutex.Lock() defer config.mutex.Unlock() for _, option := range options { if option.Type != queryType { continue } value := config.String(option.Name) if value == "" { continue } t := strings.Split(option.Name, "-") if len(t) != 2 { return "", "", fmt.Errorf("Config: Invalid query type") } return t[1], value, nil } return "", "", fmt.Errorf("Config: Query type not found") } // GetOptionValue returns a value for an option // from the configuration store. func GetOptionValue(key string) string { config.mutex.Lock() defer config.mutex.Unlock() return config.String(key) } // SetOptionValue sets a value for an option // in the configuration store. func SetOptionValue(key string, value interface{}) { config.mutex.Lock() defer config.mutex.Unlock() config.Set(key, value) } // IsOptionEnabled returns if an option is enabled. func IsOptionEnabled(key string) bool { config.mutex.Lock() defer config.mutex.Unlock() return config.Bool(key) } // generateConfig generates and updates the configuration. // Any existing values are appended to it. func generateConfig() { genMap := make(map[string]interface{}) for _, option := range options { for _, name := range []string{ "force-instance", "download-dir", "num-retries", "video-res", } { if option.Type == "path" || option.Name == name { genMap[option.Name] = config.Get(option.Name) } } } keys := config.Get("keybindings") if keys == nil { keys = make(map[string]interface{}) } genMap["keybindings"] = keys data, err := hjson.Marshal(genMap) if err != nil { printer.Error(err.Error()) } conf, err := GetPath("invidtui.conf") if err != nil { printer.Error(err.Error()) } file, err := os.OpenFile(conf, os.O_WRONLY, os.ModePerm) if err != nil { printer.Error(err.Error()) } defer file.Close() _, err = file.Write(data) if err != nil { printer.Error(err.Error()) return } if err := file.Sync(); err != nil { printer.Error(err.Error()) return } } invidtui-0.3.7/cmd/flags.go000066400000000000000000000163571454311651000155560ustar00rootroot00000000000000package cmd import ( "fmt" "os" "os/exec" "strconv" "strings" flag "github.com/spf13/pflag" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/utils" "github.com/knadh/koanf/parsers/hjson" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" ) // Option describes a command-line option. type Option struct { Name, Description string Value, Type string } var options = []Option{ { Name: "token", Description: "Specify an authorization token. Use with --force-instance.", Value: "", Type: "auth", }, { Name: "mpv-path", Description: "Specify path to the mpv executable.", Value: "mpv", Type: "path", }, { Name: "ytdl-path", Description: "Specify path to youtube-dl executable or its forks (yt-dlp, yt-dlp_x86)", Value: "", Type: "path", }, { Name: "ffmpeg-path", Description: "Specify path to ffmpeg executable.", Value: "ffmpeg", Type: "path", }, { Name: "download-dir", Description: "Specify directory to download media into.", Value: "", Type: "path", }, { Name: "search-video", Description: "Search for a video.", Value: "", Type: "search", }, { Name: "search-playlist", Description: "Search for a playlist.", Value: "", Type: "search", }, { Name: "search-channel", Description: "Search for a channel.", Value: "", Type: "search", }, { Name: "play-audio", Description: "Specify video/playlist URL to play audio from.", Value: "", Type: "play", }, { Name: "play-video", Description: "Specify video/playlist URL to play video from.", Value: "", Type: "play", }, { Name: "video-res", Description: "Set the default video resolution.", Value: "720p", Type: "other", }, { Name: "num-retries", Description: "Set the number of retries for connecting to the socket.", Value: "100", Type: "other", }, { Name: "force-instance", Description: "Force load media from specified invidious instance.", Value: "", Type: "other", }, { Name: "close-instances", Description: "Close all currently running instances.", Value: "", Type: "bool", }, { Name: "show-instances", Description: "Show a list of instances.", Value: "", Type: "bool", }, { Name: "token-link", Description: "Display a link to the token generation page.", Value: "", Type: "bool", }, { Name: "version", Description: "Print version information.", Value: "", Type: "bool", }, { Name: "generate", Description: "Generate configuration", Value: "", Type: "bool", }, } // parse parses the command-line parameters. func parse() { configFile, err := GetPath("invidtui.conf") if err != nil { printer.Error(err.Error()) } fs := flag.NewFlagSet("invidtui", flag.ContinueOnError) fs.Usage = func() { var usage string usage += fmt.Sprintf( "invidtui []\n\nConfig file is %s\n\nFlags:\n", configFile, ) fs.VisitAll(func(f *flag.Flag) { s := fmt.Sprintf(" --%s", f.Name) if len(s) <= 4 { s += "\t" } else { s += "\n \t" } s += strings.ReplaceAll(f.Usage, "\n", "\n \t") for _, name := range []string{ "token", "token-link", "search-video", "search-channel", "search-playlist", "show-instances", "play-audio", "play-video", "force-instance", "close-instances", "version", "download-dir", } { if f.Name == name { goto cmdOutPrint } } if f.Name != "num-retries" { s += fmt.Sprintf(" (default %q)", f.DefValue) } else { s += fmt.Sprintf(" (default %v)", f.DefValue) } cmdOutPrint: usage += fmt.Sprintf(s + "\n") }) printer.Print(usage, 0) } for _, option := range options { switch option.Type { case "bool": fs.Bool(option.Name, false, option.Description) default: fs.String(option.Name, option.Value, option.Description) } } if err = fs.Parse(os.Args[1:]); err != nil { printer.Error(err.Error()) } if err := config.Load(file.Provider(configFile), hjson.Parser()); err != nil { printer.Error(err.Error()) } if err := config.Load(posflag.Provider(fs, ".", config.Koanf), nil); err != nil { printer.Error(err.Error()) } } // check validates all the command-line and configuration values. func check() { parseKeybindings() getSettings() checkAuth() for _, option := range options { switch option.Type { case "path": checkExecutablePaths(option.Name, GetOptionValue(option.Name)) case "other": checkOtherOptions(option.Name, GetOptionValue(option.Name)) } } } // checkAuth parses and checks the 'token' and 'token-link' command-line parameters. // If token-link is set, it will print a link to generate an authentication token. func checkAuth() { var instance string token := GetOptionValue("token") generateLink := IsOptionEnabled("token-link") customInstance := GetOptionValue("force-instance") if (generateLink || token != "") && customInstance == "" { printer.Error("Instance is not specified") } instance = utils.GetHostname(customInstance) if generateLink { printer.Print(client.AuthLink(instance), 0) } if token == "" { return } printer.Print("Authenticating") client.SetHost(instance) if !client.IsTokenValid(token) { printer.Error("Invalid token or authentication timeout") } client.AddAuth(instance, token) SetOptionValue("instance-validated", true) } // checkExecutablePaths checks the mpv, youtube-dl and ffmpeg // application paths and the download directory. func checkExecutablePaths(pathType, path string) { if pathType != "ytdl-path" && path == "" { return } switch pathType { case "download-dir": if dir, err := os.Stat(path); err != nil || !dir.IsDir() { printer.Error(fmt.Sprintf("Cannot access %s for downloads\n", path)) } case "ytdl-path": for _, ytdl := range []string{ path, "youtube-dl", "yt-dlp", "yt-dlp_x86", } { if _, err := exec.LookPath(ytdl); err == nil { SetOptionValue("ytdl-path", ytdl) return } } if GetOptionValue("ytdl-path") == "" { printer.Error("Could not find the youtube-dl/yt-dlp/yt-dlp_x86 executables") } default: if _, err := exec.LookPath(path); err != nil { printer.Error(fmt.Sprintf("%s: Could not find %s", pathType, path)) } } } // checkOtherOptions parses and checks the command-line parameters // related to the 'other' option type. func checkOtherOptions(otherType, other string) { var resValid bool if other == "" { return } switch otherType { case "force-instance": if _, err := utils.IsValidURL(other); err != nil { printer.Error("Invalid instance URL") } case "num-retries": if _, err := strconv.Atoi(other); err != nil { printer.Error("Invalid value for num-retries") } case "video-res": for _, res := range []string{ "144p", "240p", "360p", "480p", "720p", "1080p", "1440p", "2160p", } { if res == other { resValid = true } } } if otherType == "video-res" && !resValid { printer.Error("Invalid video resolution") } } invidtui-0.3.7/cmd/keybindings.go000066400000000000000000000522411454311651000167600ustar00rootroot00000000000000package cmd import ( "fmt" "strings" "unicode" "github.com/gdamore/tcell/v2" "golang.org/x/text/cases" "golang.org/x/text/language" ) // KeyData stores the metadata for the key. type KeyData struct { Title string Context KeyContext Kb Keybinding Global bool } // Keybinding stores the keybinding. type Keybinding struct { Key tcell.Key Rune rune Mod tcell.ModMask } // Key describes the application keybinding type. type Key string // The different application keybinding types. const ( KeyMenu Key = "Menu" KeyCancel Key = "Cancel" KeySuspend Key = "Suspend" KeyInstancesList Key = "InstancesList" KeyQuit Key = "Quit" KeySearchStart Key = "SearchStart" KeySearchSuggestions Key = "SearchSuggestions" KeySearchSwitchMode Key = "SearchSwitchMode" KeySearchParameters Key = "SearchParameters" KeySearchHistoryReverse Key = "SearchHistoryReverse" KeySearchHistoryForward Key = "SearchHistoryForward" KeySearchSuggestionReverse Key = "SearchSuggestionReverse" KeySearchSuggestionForward Key = "SearchSuggestionForward" KeyDashboard Key = "Dashboard" KeyDashboardReload Key = "DashboardReload" KeyDashboardCreatePlaylist Key = "DashboardCreatePlaylist" KeyDashboardEditPlaylist Key = "DashboardEditPlaylist" KeyFilebrowserSelect Key = "FilebrowserSelect" KeyFilebrowserDirForward Key = "FilebrowserDirForward" KeyFilebrowserDirBack Key = "FilebrowserDirBack" KeyFilebrowserToggleHidden Key = "FilebrowserToggleHidden" KeyFilebrowserNewFolder Key = "FilebrowserNewFolder" KeyFilebrowserRename Key = "FilebrowserRename" KeyDownloadChangeDir Key = "DownloadChangeDir" KeyDownloadView Key = "DownloadView" KeyDownloadOptions Key = "DownloadOptions" KeyDownloadOptionSelect Key = "DownloadOptionSelect" KeyDownloadCancel Key = "DownloadCancel" KeyQueue Key = "Queue" KeyQueuePlayMove Key = "QueuePlayMove" KeyQueueSave Key = "QueueSave" KeyQueueAppend Key = "QueueAppend" KeyQueueDelete Key = "QueueDelete" KeyQueueMove Key = "QueueMove" KeyQueueCancel Key = "QueueCancel" KeyFetcher Key = "Fetcher" KeyFetcherReload Key = "FetcherReload" KeyFetcherCancel Key = "FetcherCancel" KeyFetcherReloadAll Key = "FetcherReloadAll" KeyFetcherCancelAll Key = "FetcherCancelAll" KeyFetcherClearCompleted Key = "FetcherClearCompleted" KeyPlayerOpenPlaylist Key = "PlayerOpenPlaylist" KeyPlayerHistory Key = "PlayerHistory" KeyPlayerQueueAudio Key = "PlayerQueueAudio" KeyPlayerQueueVideo Key = "PlayerQueueVideo" KeyPlayerPlayAudio Key = "PlayerPlayAudio" KeyPlayerPlayVideo Key = "PlayerPlayVideo" KeyPlayerInfo Key = "PlayerInfo" KeyPlayerInfoChangeQuality Key = "PlayerInfoChangeQuality" KeyPlayerSeekForward Key = "PlayerSeekForward" KeyPlayerSeekBackward Key = "PlayerSeekBackward" KeyPlayerStop Key = "PlayerStop" KeyPlayerToggleLoop Key = "PlayerToggleLoop" KeyPlayerToggleShuffle Key = "PlayerToggleShuffle" KeyPlayerToggleMute Key = "PlayerToggleMute" KeyPlayerTogglePlay Key = "PlayerTogglePlay" KeyPlayerPrev Key = "PlayerPrev" KeyPlayerNext Key = "PlayerNext" KeyPlayerVolumeIncrease Key = "PlayerVolumeIncrease" KeyPlayerVolumeDecrease Key = "PlayerVolumeDecrease" KeyPlayerInfoScrollUp Key = "PlayerInfoScrollUp" KeyPlayerInfoScrollDown Key = "PlayerInfoScrollDown" KeyComments Key = "Comments" KeyCommentReplies Key = "CommentReplies" KeySwitchTab Key = "SwitchTab" KeyPlaylist Key = "Playlist" KeyPlaylistSave Key = "PlaylistSave" KeyChannelVideos Key = "ChannelVideos" KeyChannelPlaylists Key = "ChannelPlaylists" KeyAudioURL Key = "AudioURL" KeyQuery Key = "Query" KeyVideoURL Key = "VideoURL" KeyLink Key = "Link" KeyAdd Key = "Add" KeyRemove Key = "Remove" KeyLoadMore Key = "LoadMore" KeyClose Key = "Close" ) // KeyContext describes the context where the keybinding is // supposed to be applied in. type KeyContext string // The different context types for keybindings. const ( KeyContextApp KeyContext = "App" KeyContextPlayer KeyContext = "Player" KeyContextCommon KeyContext = "Common" KeyContextSearch KeyContext = "Search" KeyContextDashboard KeyContext = "Dashboard" KeyContextFiles KeyContext = "Files" KeyContextDownloads KeyContext = "Downloads" KeyContextQueue KeyContext = "Queue" KeyContextFetcher KeyContext = "Fetcher" KeyContextComments KeyContext = "Comments" KeyContextStart KeyContext = "Start" KeyContextPlaylist KeyContext = "Playlist" KeyContextChannel KeyContext = "Channel" KeyContextHistory KeyContext = "History" ) var ( // OperationKeys matches the operation name (or the menu ID) with the keybinding. OperationKeys = map[Key]*KeyData{ KeyMenu: { Title: "Menu", Context: KeyContextApp, Kb: Keybinding{tcell.KeyRune, 'm', tcell.ModAlt}, Global: true, }, KeySuspend: { Title: "Suspend", Context: KeyContextApp, Kb: Keybinding{tcell.KeyCtrlZ, ' ', tcell.ModCtrl}, Global: true, }, KeyCancel: { Title: "Cancel Loading", Context: KeyContextApp, Kb: Keybinding{tcell.KeyCtrlX, ' ', tcell.ModCtrl}, Global: true, }, KeyInstancesList: { Title: "List Instances", Context: KeyContextApp, Kb: Keybinding{tcell.KeyRune, 'o', tcell.ModNone}, Global: true, }, KeyQuit: { Title: "Quit", Context: KeyContextApp, Kb: Keybinding{tcell.KeyRune, 'Q', tcell.ModNone}, Global: true, }, KeySearchStart: { Title: "Start Search", Context: KeyContextSearch, Kb: Keybinding{tcell.KeyEnter, ' ', tcell.ModNone}, }, KeySearchSuggestions: { Title: "Get Suggestions", Context: KeyContextSearch, Kb: Keybinding{tcell.KeyTab, ' ', tcell.ModNone}, }, KeySearchSwitchMode: { Title: "Switch Search Mode", Context: KeyContextSearch, Kb: Keybinding{tcell.KeyCtrlE, ' ', tcell.ModCtrl}, }, KeySearchParameters: { Title: "Set Search Parameters", Context: KeyContextSearch, Kb: Keybinding{tcell.KeyRune, 'e', tcell.ModAlt}, }, KeySearchHistoryReverse: { Context: KeyContextSearch, Kb: Keybinding{tcell.KeyUp, ' ', tcell.ModNone}, }, KeySearchHistoryForward: { Context: KeyContextSearch, Kb: Keybinding{tcell.KeyDown, ' ', tcell.ModNone}, }, KeySearchSuggestionReverse: { Context: KeyContextSearch, Kb: Keybinding{tcell.KeyUp, ' ', tcell.ModCtrl}, }, KeySearchSuggestionForward: { Context: KeyContextSearch, Kb: Keybinding{tcell.KeyDown, ' ', tcell.ModCtrl}, }, KeyDashboard: { Title: "Dashboard", Context: KeyContextDashboard, Kb: Keybinding{tcell.KeyCtrlD, ' ', tcell.ModCtrl}, }, KeyDashboardReload: { Title: "Reload Dashboard", Context: KeyContextDashboard, Kb: Keybinding{tcell.KeyCtrlT, ' ', tcell.ModCtrl}, }, KeyDashboardCreatePlaylist: { Title: "Create Playlist", Context: KeyContextDashboard, Kb: Keybinding{tcell.KeyRune, 'c', tcell.ModNone}, }, KeyDashboardEditPlaylist: { Title: "Edit playlist", Context: KeyContextDashboard, Kb: Keybinding{tcell.KeyRune, 'e', tcell.ModNone}, }, KeyFilebrowserSelect: { Title: "Select entry", Context: KeyContextFiles, Kb: Keybinding{tcell.KeyEnter, ' ', tcell.ModNone}, }, KeyFilebrowserDirForward: { Title: "Go forward", Context: KeyContextFiles, Kb: Keybinding{tcell.KeyUp, ' ', tcell.ModCtrl}, }, KeyFilebrowserDirBack: { Title: "Go back", Context: KeyContextFiles, Kb: Keybinding{tcell.KeyDown, ' ', tcell.ModCtrl}, }, KeyFilebrowserToggleHidden: { Title: "Toggle hidden", Context: KeyContextFiles, Kb: Keybinding{tcell.KeyCtrlG, ' ', tcell.ModCtrl}, }, KeyFilebrowserNewFolder: { Title: "New folder", Context: KeyContextFiles, Kb: Keybinding{tcell.KeyCtrlN, ' ', tcell.ModCtrl}, }, KeyFilebrowserRename: { Title: "Rename", Context: KeyContextFiles, Kb: Keybinding{tcell.KeyCtrlB, ' ', tcell.ModCtrl}, }, KeyDownloadChangeDir: { Title: "Change download directory", Context: KeyContextDownloads, Kb: Keybinding{tcell.KeyRune, 'Y', tcell.ModAlt}, }, KeyDownloadView: { Title: "Show Downloads", Context: KeyContextDownloads, Kb: Keybinding{tcell.KeyRune, 'Y', tcell.ModNone}, }, KeyDownloadOptions: { Title: "Download Video", Context: KeyContextDownloads, Kb: Keybinding{tcell.KeyRune, 'y', tcell.ModNone}, }, KeyDownloadOptionSelect: { Title: "Select Option", Context: KeyContextDownloads, Kb: Keybinding{tcell.KeyEnter, ' ', tcell.ModNone}, }, KeyDownloadCancel: { Title: "Cancel Download", Context: KeyContextDownloads, Kb: Keybinding{tcell.KeyRune, 'x', tcell.ModNone}, }, KeyQueue: { Title: "Show Queue", Context: KeyContextQueue, Kb: Keybinding{tcell.KeyRune, 'q', tcell.ModNone}, }, KeyQueuePlayMove: { Title: "Play/Replace", Context: KeyContextQueue, Kb: Keybinding{tcell.KeyEnter, ' ', tcell.ModNone}, }, KeyQueueSave: { Title: "Save Queue", Context: KeyContextQueue, Kb: Keybinding{tcell.KeyCtrlS, ' ', tcell.ModCtrl}, }, KeyQueueAppend: { Title: "Append To Queue", Context: KeyContextQueue, Kb: Keybinding{tcell.KeyCtrlA, ' ', tcell.ModCtrl}, }, KeyQueueDelete: { Title: "Delete", Context: KeyContextQueue, Kb: Keybinding{tcell.KeyRune, 'd', tcell.ModNone}, }, KeyQueueMove: { Title: "Move", Context: KeyContextQueue, Kb: Keybinding{tcell.KeyRune, 'M', tcell.ModNone}, }, KeyQueueCancel: { Title: "Cancel Loading", Context: KeyContextQueue, Kb: Keybinding{tcell.KeyRune, 'X', tcell.ModNone}, }, KeyFetcher: { Title: "Show Media Fetcher", Context: KeyContextFetcher, Kb: Keybinding{tcell.KeyRune, 'f', tcell.ModNone}, }, KeyFetcherReload: { Title: "Reload", Context: KeyContextFetcher, Kb: Keybinding{tcell.KeyRune, 'e', tcell.ModNone}, }, KeyFetcherCancel: { Title: "Cancel", Context: KeyContextFetcher, Kb: Keybinding{tcell.KeyRune, 'x', tcell.ModNone}, }, KeyFetcherReloadAll: { Title: "Reload All", Context: KeyContextFetcher, Kb: Keybinding{tcell.KeyRune, 'E', tcell.ModNone}, }, KeyFetcherCancelAll: { Title: "Cancel All", Context: KeyContextFetcher, Kb: Keybinding{tcell.KeyRune, 'X', tcell.ModNone}, }, KeyFetcherClearCompleted: { Title: "Clear", Context: KeyContextFetcher, Kb: Keybinding{tcell.KeyRune, 'c', tcell.ModNone}, }, KeyPlayerOpenPlaylist: { Title: "Open Playlist", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyCtrlO, ' ', tcell.ModCtrl}, Global: true, }, KeyPlayerHistory: { Title: "Show History", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'h', tcell.ModAlt}, Global: true, }, KeyPlayerQueueAudio: { Title: "Queue Audio", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'a', tcell.ModNone}, Global: true, }, KeyPlayerQueueVideo: { Title: "Queue Video", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'v', tcell.ModNone}, Global: true, }, KeyPlayerPlayAudio: { Title: "Play Audio", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'A', tcell.ModNone}, Global: true, }, KeyPlayerPlayVideo: { Title: "Play Video", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'V', tcell.ModNone}, Global: true, }, KeyPlayerInfo: { Title: "Track Information", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, ' ', tcell.ModAlt}, Global: true, }, KeyPlayerInfoChangeQuality: { Title: "Change Image Quality", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, ':', tcell.ModAlt}, Global: true, }, KeyPlayerSeekForward: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRight, ' ', tcell.ModCtrl}, Global: true, }, KeyPlayerSeekBackward: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyLeft, ' ', tcell.ModCtrl}, Global: true, }, KeyPlayerStop: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'S', tcell.ModNone}, Global: true, }, KeyPlayerToggleLoop: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'l', tcell.ModNone}, Global: true, }, KeyPlayerToggleShuffle: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 's', tcell.ModNone}, Global: true, }, KeyPlayerToggleMute: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'm', tcell.ModNone}, Global: true, }, KeyPlayerTogglePlay: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, ' ', tcell.ModNone}, Global: true, }, KeyPlayerPrev: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, '<', tcell.ModNone}, Global: true, }, KeyPlayerNext: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, '>', tcell.ModNone}, Global: true, }, KeyPlayerVolumeIncrease: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, '=', tcell.ModNone}, Global: true, }, KeyPlayerVolumeDecrease: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, '-', tcell.ModNone}, Global: true, }, KeyPlayerInfoScrollUp: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyUp, ' ', tcell.ModCtrl | tcell.ModAlt}, Global: true, }, KeyPlayerInfoScrollDown: { Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyDown, ' ', tcell.ModCtrl | tcell.ModAlt}, Global: true, }, KeyAudioURL: { Title: "Play audio from URL", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'b', tcell.ModNone}, }, KeyVideoURL: { Title: "Play video from URL", Context: KeyContextPlayer, Kb: Keybinding{tcell.KeyRune, 'B', tcell.ModNone}, }, KeyPlaylistSave: { Title: "Save Playlist", Context: KeyContextPlaylist, Kb: Keybinding{tcell.KeyCtrlS, ' ', tcell.ModCtrl}, }, KeyComments: { Title: "Show Comments", Context: KeyContextComments, Kb: Keybinding{tcell.KeyRune, 'C', tcell.ModNone}, }, KeyCommentReplies: { Title: "Expand replies", Context: KeyContextComments, Kb: Keybinding{tcell.KeyEnter, ' ', tcell.ModNone}, }, KeySwitchTab: { Title: "Switch tab", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyTab, ' ', tcell.ModNone}, }, KeyPlaylist: { Title: "Show Playlist", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyRune, 'i', tcell.ModNone}, }, KeyChannelVideos: { Title: "Show Channel videos", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyRune, 'u', tcell.ModNone}, }, KeyChannelPlaylists: { Title: "Show Channel playlists", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyRune, 'U', tcell.ModNone}, }, KeyQuery: { Title: "Query", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyRune, '/', tcell.ModNone}, }, KeyLink: { Title: "Show Link", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyRune, ';', tcell.ModNone}, }, KeyAdd: { Title: "Add", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyRune, '+', tcell.ModNone}, }, KeyRemove: { Title: "Remove", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyRune, '_', tcell.ModNone}, }, KeyLoadMore: { Title: "Load more", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyEnter, ' ', tcell.ModNone}, }, KeyClose: { Title: "Close page", Context: KeyContextCommon, Kb: Keybinding{tcell.KeyEscape, ' ', tcell.ModNone}, }, } // Keys match the keybinding to the key type. Keys map[KeyContext]map[Keybinding]Key translateKeys = map[string]string{ "Pgup": "PgUp", "Pgdn": "PgDn", "Pageup": "PgUp", "Pagedown": "PgDn", "Upright": "UpRight", "Downright": "DownRight", "Upleft": "UpLeft", "Downleft": "DownLeft", "Prtsc": "Print", "Backspace": "Backspace2", } ) // OperationData returns the key data associated with // the provided keyID and operation name. func OperationData(operation Key) *KeyData { return OperationKeys[operation] } // KeyOperation returns the operation name for the provided keyID // and the keyboard event. func KeyOperation(event *tcell.EventKey, keyContexts ...KeyContext) Key { if Keys == nil { Keys = make(map[KeyContext]map[Keybinding]Key) for keyName, key := range OperationKeys { if Keys[key.Context] == nil { Keys[key.Context] = make(map[Keybinding]Key) } Keys[key.Context][key.Kb] = keyName } } ch := event.Rune() if event.Key() != tcell.KeyRune { ch = ' ' } kb := Keybinding{event.Key(), ch, event.Modifiers()} for _, contexts := range [][]KeyContext{ keyContexts, { KeyContextApp, KeyContextCommon, KeyContextPlayer, }, } { for _, context := range contexts { if operation, ok := Keys[context][kb]; ok { return operation } } } return "" } // KeyName formats and returns the key's name. func KeyName(kb Keybinding) string { if kb.Key == tcell.KeyRune { keyname := string(kb.Rune) if kb.Rune == ' ' { keyname = "Space" } if kb.Mod&tcell.ModAlt != 0 { keyname = "Alt+" + keyname } return keyname } return tcell.NewEventKey(kb.Key, kb.Rune, kb.Mod).Name() } // parseKeybindings parses the keybindings from the configuration. func parseKeybindings() { if !config.Exists("keybindings") { return } kbMap := config.StringMap("keybindings") if len(kbMap) == 0 { return } keyNames := make(map[string]tcell.Key) for key, names := range tcell.KeyNames { keyNames[names] = key } for keyType, key := range kbMap { checkBindings(keyType, key, keyNames) } keyErrors := make(map[Keybinding]string) for keyType, keydata := range OperationKeys { for existing, data := range OperationKeys { if data.Kb == keydata.Kb && data.Title != keydata.Title { if data.Context == keydata.Context || data.Global || keydata.Global { goto KeyError } continue KeyError: if _, ok := keyErrors[keydata.Kb]; !ok { keyErrors[keydata.Kb] = fmt.Sprintf("- %s will override %s (%s)", keyType, existing, KeyName(keydata.Kb)) } } } } if len(keyErrors) > 0 { err := "Config: The following keybindings will conflict:\n" for _, ke := range keyErrors { err += ke + "\n" } printer.Error(strings.TrimRight(err, "\n")) } } // checkBindings validates the provided keybinding. // //gocyclo:ignore func checkBindings(keyType, key string, keyNames map[string]tcell.Key) { var runes []rune var keys []tcell.Key if _, ok := OperationKeys[Key(keyType)]; !ok { printer.Error(fmt.Sprintf("Config: Invalid key type %s", keyType)) } keybinding := Keybinding{ Key: tcell.KeyRune, Rune: ' ', Mod: tcell.ModNone, } tokens := strings.FieldsFunc(key, func(c rune) bool { return unicode.IsSpace(c) || c == '+' }) for _, token := range tokens { if len(token) > 1 { token = cases.Title(language.Und).String(token) } else if len(token) == 1 { keybinding.Rune = rune(token[0]) runes = append(runes, keybinding.Rune) continue } if translated, ok := translateKeys[token]; ok { token = translated } switch token { case "Ctrl": keybinding.Mod |= tcell.ModCtrl case "Alt": keybinding.Mod |= tcell.ModAlt case "Shift": keybinding.Mod |= tcell.ModShift case "Space", "Plus": keybinding.Rune = ' ' if token == "Plus" { keybinding.Rune = '+' } runes = append(runes, keybinding.Rune) default: if key, ok := keyNames[token]; ok { keybinding.Key = key keybinding.Rune = ' ' keys = append(keys, keybinding.Key) } } } if keys != nil && runes != nil || len(runes) > 1 || len(keys) > 1 { printer.Error( fmt.Sprintf("Config: More than one key entered for %s (%s)", keyType, key), ) } if keybinding.Mod&tcell.ModShift != 0 { keybinding.Rune = unicode.ToUpper(keybinding.Rune) if unicode.IsLetter(keybinding.Rune) { keybinding.Mod &^= tcell.ModShift } } if keybinding.Mod&tcell.ModCtrl != 0 { var modKey string switch { case len(keys) > 0: if key, ok := tcell.KeyNames[keybinding.Key]; ok { modKey = key } case len(runes) > 0: if keybinding.Rune == ' ' { modKey = "Space" } else { modKey = string(unicode.ToUpper(keybinding.Rune)) } } if modKey != "" { modKey = "Ctrl-" + modKey if key, ok := keyNames[modKey]; ok { keybinding.Key = key keybinding.Rune = ' ' keys = append(keys, keybinding.Key) } } } if keys == nil && runes == nil { printer.Error( fmt.Sprintf("Config: No key specified or invalid keybinding for %s (%s)", keyType, key), ) } OperationKeys[Key(keyType)].Kb = keybinding } invidtui-0.3.7/cmd/printer.go000066400000000000000000000027701454311651000161370ustar00rootroot00000000000000package cmd import ( "fmt" "os" "time" "github.com/theckman/yacspin" ) // Printer describes the terminal printing configuration. type Printer struct { spinner *yacspin.Spinner } var printer Printer // setup sets up the printer. func (p *Printer) setup() { spinner, err := yacspin.New( yacspin.Config{ Frequency: 100 * time.Millisecond, CharSet: yacspin.CharSets[59], Message: "Loading", Suffix: " ", StopCharacter: "", StopMessage: "", StopFailCharacter: "[!] \b", ColorAll: true, Colors: []string{"bold", "fgYellow"}, StopFailColors: []string{"bold", "fgRed"}, }) if err != nil { fmt.Println(err) os.Exit(1) } p.spinner = spinner p.spinner.Start() } // Print displays a loading spinner and a message. func (p *Printer) Print(message string, status ...int) { if status != nil { p.Stop(message) os.Exit(status[0]) } p.spinner.Message(message) } // Stop stops the spinner. func (p *Printer) Stop(message ...string) { m := "" if message != nil { m = message[0] } p.spinner.StopMessage(m) p.spinner.Stop() } // Error displays an error and stops the application. func (p *Printer) Error(message string) { p.spinner.StopFailMessage(message) p.spinner.StopFail() os.Exit(1) } // PrintError prints an error to the screen. func PrintError(message string, err ...error) { if err != nil { message = message + ": " + err[0].Error() } printer.spinner.Start() printer.Error(message) } invidtui-0.3.7/cmd/settings.go000066400000000000000000000070101454311651000163040ustar00rootroot00000000000000package cmd import ( "bufio" "encoding/json" "fmt" "io" "os" "strings" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/resolver" "github.com/darkhz/invidtui/utils" ) // SettingsData describes the format to store the application settings. type SettingsData struct { Credentials []client.Credential `json:"credentials"` SearchHistory []string `json:"searchHistory"` PlayHistory []PlayHistorySettings `json:"playHistory"` PlayerStates []string `json:"playerStates"` } // PlayHistorySettings describes the format to store the play history. type PlayHistorySettings struct { Type string `json:"type"` Title string `json:"title"` Author string `json:"author"` VideoID string `json:"videoId"` PlaylistID string `json:"playlistId"` AuthorID string `json:"authorId"` } // Settings stores the application settings. var Settings SettingsData // SaveSettings saves the application settings. func SaveSettings() { Settings.Credentials = client.GetAuthCredentials() Settings.SearchHistory = utils.Deduplicate(Settings.SearchHistory) data, err := json.MarshalIndent(Settings, "", " ") if err != nil { printer.Error(fmt.Sprintf("Settings: Cannot encode data: %s", err)) } file, err := GetPath("settings.json") if err != nil { printer.Error("Settings: Cannot get store path") } fd, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_SYNC, os.ModePerm) if err != nil { printer.Error(fmt.Sprintf("Settings: Cannot open file: %s", err)) } defer fd.Close() _, err = fd.Write(data) if err != nil { printer.Error(fmt.Sprintf("Settings: Cannot save data: %s", err)) } } // getSettings retrives the settings from the settings file. func getSettings() { getOldSettings() file, err := GetPath("settings.json") if err != nil { printer.Error("Settings: Cannot create/get store path") } fd, err := os.OpenFile(file, os.O_RDONLY, os.ModePerm) if err != nil { printer.Error("Settings: Cannot open file") } defer fd.Close() err = resolver.DecodeJSONReader(fd, &Settings) if err != nil && err != io.EOF { printer.Error("Settings: Cannot parse values") } client.SetAuthCredentials(Settings.Credentials) } // getOldSettings retreives the settings stored in various files // and merges them according to the settings format. func getOldSettings() { for _, files := range []struct { Type, File string }{ {"Auth", "auth.json"}, {"Config", "config"}, {"SearchHistory", "history"}, {"State", "state"}, {"PlayHistory", "playhistory.json"}, } { file, err := GetPath(files.File, struct{}{}) if err != nil { continue } fd, err := os.OpenFile(file, os.O_RDONLY, os.ModePerm) if err != nil { continue } switch files.Type { case "Auth": err = resolver.DecodeJSONReader(fd, &Settings.Credentials) case "PlayHistory": err = resolver.DecodeJSONReader(fd, &Settings.PlayHistory) case "Config", "State", "SearchHistory": scanner := bufio.NewScanner(fd) for scanner.Scan() { line := scanner.Text() if line == "" { continue } switch files.Type { case "Config": values := strings.Split(line, "=") if len(values) != 2 { continue } SetOptionValue(values[0], values[1]) case "State": Settings.PlayerStates = strings.Split(line, ",") case "SearchHistory": Settings.SearchHistory = append(Settings.SearchHistory, line) } } err = scanner.Err() } fd.Close() if err != nil && err != io.EOF { printer.Error(fmt.Sprintf("Settings: Could not parse %s", files.File)) } } } invidtui-0.3.7/go.mod000066400000000000000000000033151454311651000144640ustar00rootroot00000000000000module github.com/darkhz/invidtui go 1.18 require ( github.com/darkhz/mpvipc v0.0.0-20231124135332-eb73ba48ad46 github.com/darkhz/tview v0.0.0-20231221085230-b7c392e224e5 github.com/davidmytton/url-verifier v1.0.0 github.com/etherlabsio/go-m3u8 v1.0.0 github.com/gammazero/deque v0.2.1 github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73 github.com/hjson/hjson-go/v4 v4.3.1 github.com/knadh/koanf/parsers/hjson v0.1.0 github.com/knadh/koanf/providers/file v0.1.0 github.com/knadh/koanf/providers/posflag v0.1.0 github.com/knadh/koanf/v2 v2.0.1 github.com/mitchellh/go-homedir v1.1.0 github.com/schollz/progressbar/v3 v3.14.1 github.com/spf13/pflag v1.0.5 github.com/theckman/yacspin v0.13.12 github.com/ugorji/go/codec v1.2.12 golang.org/x/sync v0.5.0 golang.org/x/text v0.14.0 ) require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/rivo/uniseg v0.4.4 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/term v0.15.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) invidtui-0.3.7/go.sum000066400000000000000000000263071454311651000145170ustar00rootroot00000000000000github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/darkhz/mpvipc v0.0.0-20231124135332-eb73ba48ad46 h1:Q+VU+REB8rA55FZWlvDXDRK34zG0HDAVJIN72eX7RAE= github.com/darkhz/mpvipc v0.0.0-20231124135332-eb73ba48ad46/go.mod h1:Dk4kPIU8+4lXO7wE04XDYb+U6999lrl59SMoGx4qjmA= github.com/darkhz/tview v0.0.0-20231221085230-b7c392e224e5 h1:db0n3EOdHQRtSPQRrCxdpSEpqGlJdcZ2NTfDdwT1c94= github.com/darkhz/tview v0.0.0-20231221085230-b7c392e224e5/go.mod h1:RpzjEizwEsrO70oSS+s2uMB07y8SXFkL++b0etXO5TQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmytton/url-verifier v1.0.0 h1:TIdwZ+rWVnfnJPMD/DclwEJ0XewV4jkr0qEcX/58n+4= github.com/davidmytton/url-verifier v1.0.0/go.mod h1:8mPy8FL7r56f0WpwZm9+PvPwc3i1lX4eKaF8mosx4WE= github.com/etherlabsio/go-m3u8 v1.0.0 h1:d3HJVr8wlbvJO20ksKEyvDYf4bcM7v8YV3W83fHswL0= github.com/etherlabsio/go-m3u8 v1.0.0/go.mod h1:RzDiaXgaYnIEzZUmVUD/xMRFR7bY7U5JaCnp8XYLmXU= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gammazero/deque v0.2.1 h1:qSdsbG6pgp6nL7A0+K/B7s12mcCY/5l5SIUpMOl+dC0= github.com/gammazero/deque v0.2.1/go.mod h1:LFroj8x4cMYCukHJDbxFCkT+r9AndaJnFMuZDV34tuU= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73 h1:SeDV6ZUSVlTAUUPdMzPXgMyj96z+whQJRRUff8dIeic= github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73/go.mod h1:pwzJMyH4Hd0AZMJkWQ+/g01dDvYWEvmJuaiRU71Xl8k= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/hjson/hjson-go/v4 v4.3.1 h1:wfmDwHGxjzmYKXRFL0Qr9nonY/Xxe5y7IalwjlY7ekA= github.com/hjson/hjson-go/v4 v4.3.1/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/hjson v0.1.0 h1:RDGXMhUsDWCu1Smu1l4i3CEszscv71TL9lxLiO5l5Zs= github.com/knadh/koanf/parsers/hjson v0.1.0/go.mod h1:IaKVQ6ptwA+VCbBnzNccMkoqkoQKEUSnnktr2iT8MiI= github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf3ODrc//Lp+c= github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA= github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U= github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0= github.com/knadh/koanf/v2 v2.0.1 h1:1dYGITt1I23x8cfx8ZnldtezdyaZtfAuRtIFOiRzK7g= github.com/knadh/koanf/v2 v2.0.1/go.mod h1:ZeiIlIDXTE7w1lMT6UVcNiRAS2/rCeLn/GdLNvY1Dus= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/schollz/progressbar/v3 v3.14.1 h1:VD+MJPCr4s3wdhTc7OEJ/Z3dAeBzJ7yKH/P4lC5yRTI= github.com/schollz/progressbar/v3 v3.14.1/go.mod h1:Zc9xXneTzWXF81TGoqL71u0sBPjULtEHYtj/WVgVy8E= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= invidtui-0.3.7/invidious/000077500000000000000000000000001454311651000153655ustar00rootroot00000000000000invidtui-0.3.7/invidious/channel.go000066400000000000000000000050001454311651000173170ustar00rootroot00000000000000package invidious import ( "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/resolver" ) const channelFields = "?fields=title,authorId,author,description,viewCount,error&hl=en" // ChannelData stores channel related data. type ChannelData struct { Title string `json:"title"` ChannelID string `json:"authorId"` Author string `json:"author"` Description string `json:"description"` ViewCount int64 `json:"viewCount"` Continuation string `json:"continuation"` Videos []PlaylistVideo `json:"videos"` Playlists []PlaylistData `json:"playlists"` } // Channel retrieves information about a channel. func Channel(id, stype, params string, channel ...ChannelData) (ChannelData, error) { var err error var query string var data ChannelData client.Cancel() if channel != nil { data = channel[0] goto GetData } query = "channels/" + id + channelFields // Get the channel data first. data, err = decodeChannelData(query) if err != nil { return ChannelData{}, err } GetData: // Then get the data associated with the provided channel type (stype). query = "channels/" + id + "/" + stype + params d, err := decodeChannelData(query) if err != nil { return ChannelData{}, err } data.Videos = d.Videos data.Playlists = d.Playlists data.Continuation = d.Continuation return data, nil } // ChannelVideos retrieves video information from a channel. func ChannelVideos(id, continuation string) (ChannelData, error) { params := "?fields=videos,continuation,error" if continuation != "" { params += "&continuation=" + continuation } return Channel(id, "videos", params) } // ChannelPlaylists loads only the playlists present in the channel. func ChannelPlaylists(id, continuation string) (ChannelData, error) { params := "?fields=playlists,continuation,error" if continuation != "" { params += "&continuation=" + continuation } return Channel(id, "playlists", params) } // ChannelSearch searches for a query string in the channel. func ChannelSearch(id, searchText string, page int) ([]SearchData, int, error) { return Search("channel", searchText, nil, page, id) } // decodeChannelData sends a channel query, parses and returns the response. func decodeChannelData(query string) (ChannelData, error) { var data ChannelData res, err := client.Fetch(client.Ctx(), query) if err != nil { return ChannelData{}, err } defer res.Body.Close() err = resolver.DecodeJSONReader(res.Body, &data) return data, err } invidtui-0.3.7/invidious/comments.go000066400000000000000000000031041454311651000175370ustar00rootroot00000000000000package invidious import ( "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/resolver" ) // CommentsData stores comments and its continuation data. type CommentsData struct { Comments []CommentData `json:"comments"` Continuation string `json:"continuation"` } // CommentData stores information about a comment. type CommentData struct { Verified bool `json:"verified"` Author string `json:"author"` AuthorID string `json:"authorId"` AuthorURL string `json:"authorUrl"` Content string `json:"content"` PublishedText string `json:"publishedText"` LikeCount int `json:"likeCount"` CommentID string `json:"commentId"` AuthorIsChannelOwner bool `json:"authorIsChannelOwner"` Replies CommentReply `json:"replies"` } // CommentReply stores information about comment replies. type CommentReply struct { ReplyCount int `json:"replyCount"` Continuation string `json:"continuation"` } // Comments retrieves comments for a video. func Comments(id string, continuation ...string) (CommentsData, error) { var data CommentsData client.Cancel() query := "comments/" + id + "?hl=en" if continuation != nil { query += "&continuation=" + continuation[0] } res, err := client.Fetch(client.Ctx(), query) if err != nil { return CommentsData{}, err } defer res.Body.Close() err = resolver.DecodeJSONReader(res.Body, &data) if err != nil { return CommentsData{}, err } return data, nil } invidtui-0.3.7/invidious/download.go000066400000000000000000000014171454311651000175260ustar00rootroot00000000000000package invidious import ( "context" "fmt" "net/http" "net/url" "os" "path/filepath" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/cmd" ) // DownloadParams returns parameters that are used to download a file. func DownloadParams(ctx context.Context, id, itag, filename string) (*http.Response, *os.File, error) { dir := cmd.GetOptionValue("download-dir") uri, err := url.Parse(getLatestURL(id, itag)) if err != nil { return nil, nil, fmt.Errorf("Video: Cannot parse download URL") } res, err := client.Get(ctx, uri.RequestURI(), client.Token()) if err != nil { return nil, nil, err } file, err := os.OpenFile(filepath.Join(dir, filename), os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return nil, nil, err } return res, file, err } invidtui-0.3.7/invidious/feed.go000066400000000000000000000021071454311651000166170ustar00rootroot00000000000000package invidious import ( "strconv" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/resolver" ) // FeedData stores videos in the user's feed. type FeedData struct { Videos []FeedVideos `json:"videos"` } // FeedVideos stores information about a video in the user's feed. type FeedVideos struct { Type string `json:"type"` Title string `json:"title"` VideoID string `json:"videoId"` LengthSeconds int64 `json:"lengthSeconds"` Author string `json:"author"` AuthorID string `json:"authorId"` AuthorURL string `json:"authorUrl"` PublishedText string `json:"publishedText"` ViewCount int64 `json:"viewCount"` } // Feed retrieves videos from a user's feed. func Feed(page int) (FeedData, error) { var data FeedData query := "auth/feed?hl=en&page=" + strconv.Itoa(page) res, err := client.Fetch(client.Ctx(), query, client.Token()) if err != nil { return FeedData{}, err } defer res.Body.Close() err = resolver.DecodeJSONReader(res.Body, &data) if err != nil { return FeedData{}, err } return data, nil } invidtui-0.3.7/invidious/playlist.go000066400000000000000000000204041454311651000175550ustar00rootroot00000000000000package invidious import ( "context" "fmt" "net/url" "os" "sort" "strconv" "strings" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/resolver" "github.com/darkhz/invidtui/utils" "github.com/etherlabsio/go-m3u8/m3u8" ) const ( PlaylistEntryPrefix = "invidtui.video." playlistFields = "?fields=title,playlistId,author,description,videoCount,viewCount,videos,error&hl=en" playlistVideoFields = "?fields=videoCount,videos,error" ) // PlaylistData stores information about a playlist. type PlaylistData struct { Title string `json:"title"` PlaylistID string `json:"playlistId"` Author string `json:"author"` AuthorID string `json:"authorId"` Description string `json:"description"` VideoCount int64 `json:"videoCount"` ViewCount int64 `json:"viewCount"` Videos []PlaylistVideo `json:"videos"` } // PlaylistVideo stores information about a video in the playlist. type PlaylistVideo struct { Title string `json:"title"` Author string `json:"author"` Index int32 `json:"index"` IndexID string `json:"indexId"` VideoID string `json:"videoId"` AuthorID string `json:"authorId"` LengthSeconds int64 `json:"lengthSeconds"` } // Playlist retrieves a playlist and its videos. func Playlist(id string, auth bool, page int, ctx ...context.Context) (PlaylistData, error) { if ctx == nil { ctx = append(ctx, client.Ctx()) } return getPlaylist(ctx[0], id, playlistFields, page, auth) } // PlaylistVideos retrieves a playlist's videos only. func PlaylistVideos(ctx context.Context, id string, auth bool, add func(stats [3]int64)) ([]VideoData, error) { var idx, skipped int64 page := 1 videoCount := int64(2) stats := [3]int64{int64(page), 0, 0} videoMap := make(map[int32]PlaylistVideo) for idx < videoCount-1 { select { case <-ctx.Done(): return nil, ctx.Err() default: } playlist, err := getPlaylist(ctx, id, playlistVideoFields, page, auth) if err != nil { return nil, err } if len(playlist.Videos) == 0 || (len(videoMap) > 0 && skipped == int64(len(videoMap))) { return nil, fmt.Errorf("No more videos") } videoCount = playlist.VideoCount stats[2] = videoCount for _, video := range playlist.Videos { if _, ok := videoMap[video.Index]; ok { continue } videoMap[video.Index] = video stats[1] += 1 } idx = int64(playlist.Videos[len(playlist.Videos)-1].Index) page++ stats[0] = int64(page) add(stats) } idx = 0 indexKeys := make([]int32, len(videoMap)) for index := range videoMap { indexKeys[idx] = index idx++ } sort.Slice(indexKeys, func(i, j int) bool { return indexKeys[i] < indexKeys[j] }) videos := make([]VideoData, len(videoMap)) for _, key := range indexKeys { video := videoMap[key] videos[key] = VideoData{ VideoID: video.VideoID, Title: video.Title, LengthSeconds: video.LengthSeconds, Author: video.Author, AuthorID: video.AuthorID, } } return videos, nil } // UserPlaylists retrieves the user's playlists. func UserPlaylists() ([]PlaylistData, error) { var data []PlaylistData res, err := client.Fetch(client.Ctx(), "auth/playlists/", client.Token()) if err != nil { return nil, err } defer res.Body.Close() err = resolver.DecodeJSONReader(res.Body, &data) if err != nil { return nil, err } return data, nil } // CreatePlaylist creates a playlist for the user. func CreatePlaylist(title, privacy string) error { createFormat := fmt.Sprintf( `{"title": "%s", "privacy": "%s"}`, title, privacy, ) _, err := client.Send("auth/playlists/", createFormat, client.Token()) return err } // EditPlaylist edits a user's playlist properties. func EditPlaylist(id, title, description, privacy string) error { editFormat := fmt.Sprintf( `{"title": "%s", "description": "%s", "privacy": "%s"}`, title, description, privacy, ) _, err := client.Modify("auth/playlists/"+id, editFormat, client.Token()) return err } // RemovePlaylist removes a user's playlist. func RemovePlaylist(id string) error { _, err := client.Remove("auth/playlists/"+id, client.Token()) return err } // AddVideoToPlaylist adds a video to the user's playlist. func AddVideoToPlaylist(plid, videoID string) error { videoFormat := fmt.Sprintf(`{"videoId":"%s"}`, videoID) _, err := client.Send("auth/playlists/"+plid+"/videos", videoFormat, client.Token()) return err } // RemoveVideoFromPlaylist removes a video from the user's playlist. func RemoveVideoFromPlaylist(plid, index string) error { _, err := client.Remove("auth/playlists/"+plid+"/videos/"+index, client.Token()) return err } // GeneratePlaylist generates a playlist file. // //gocyclo:ignore func GeneratePlaylist(file string, list []VideoData, flags int, appendToFile bool) (string, int, error) { var skipped int var ignored []m3u8.Item var fileEntries map[string]struct{} if len(list) == 0 { return "", flags, fmt.Errorf("Playlist Generator: No videos found") } playlist := m3u8.NewPlaylist() flags |= os.O_TRUNC if (flags & os.O_APPEND) != 0 { flags ^= os.O_APPEND } if appendToFile { fileEntries = make(map[string]struct{}) existing, err := m3u8.ReadFile(file) if err != nil { return "", flags, err } for _, e := range existing.Items { var id string var item m3u8.Item add := true switch v := e.(type) { case *m3u8.SessionDataItem: if v.DataID == "" || !strings.HasPrefix(v.DataID, PlaylistEntryPrefix) { continue } utils.DecodeSessionData(*v.Value, func(prop, value string) { switch prop { case "id": id = value case "authorId": if value == "" { add = false ignored = append(ignored, v) } } }) item = v case *m3u8.SegmentItem: if strings.HasPrefix(v.Segment, "#") { add = false ignored = append(ignored, v) } segment := strings.TrimPrefix(v.Segment, "#") uri, err := utils.IsValidURL(segment) if err != nil { continue } id = uri.Query().Get("id") if id == "" { id, _ = CheckLiveURL(segment, true) } item = v } if add && item != nil { playlist.Items = append(playlist.Items, item) } if id != "" { fileEntries[id] = struct{}{} } } } for _, data := range list { var filename, length string if data.VideoID == "" { continue } if appendToFile && fileEntries != nil { if _, ok := fileEntries[data.VideoID]; ok { skipped++ continue } } if data.LiveNow { filename = data.HlsURL length = "Live" } else { filename = getLatestURL(data.VideoID, "") length = utils.FormatDuration(data.LengthSeconds) } if data.MediaType == "" { data.MediaType = "Audio" } value := fmt.Sprintf( "id=%s,title=%s,author=%s,authorId=%s,length=%s,mediatype=%s", data.VideoID, url.QueryEscape(data.Title), url.QueryEscape(data.Author), data.AuthorID, length, data.MediaType, ) comment := fmt.Sprintf( "%s - %s", data.Title, data.Author, ) session := m3u8.SessionDataItem{ DataID: PlaylistEntryPrefix + data.VideoID, Value: &value, URI: &filename, } segment := m3u8.SegmentItem{ Duration: float64(data.LengthSeconds), Segment: filename, Comment: &comment, } if data.Author == "" && data.AuthorID == "" { segment.Segment = "# " + filename ignored = append(ignored, []m3u8.Item{&session, &segment}...) continue } playlist.Items = append(playlist.Items, []m3u8.Item{&session, &segment}...) } if ignored != nil { playlist.Items = append(playlist.Items, ignored...) } if appendToFile && skipped == len(list) { return "", flags, fmt.Errorf("Playlist Generator: No new items in playlist to append") } return playlist.String(), flags, nil } // getPlaylist queries for and returns a playlist according to the provided parameters. func getPlaylist(ctx context.Context, id, param string, page int, auth bool) (PlaylistData, error) { var data PlaylistData query := "playlists/" + id + param + "&page=" + strconv.Itoa(page) if auth { query = "auth/" + query } res, err := client.Fetch(ctx, query, client.Token()) if err != nil { return PlaylistData{}, err } defer res.Body.Close() err = resolver.DecodeJSONReader(res.Body, &data) if err != nil { return PlaylistData{}, err } return data, nil } invidtui-0.3.7/invidious/search.go000066400000000000000000000047511454311651000171700ustar00rootroot00000000000000package invidious import ( "net/url" "strconv" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/resolver" ) const searchField = "&fields=type,title,videoId,playlistId,author,authorId,publishedText,description,videoCount,subCount,lengthSeconds,videos,liveNow,error&hl=en" // SearchData stores information about a search result. type SearchData struct { Type string `json:"type"` Title string `json:"title"` AuthorID string `json:"authorId"` VideoID string `json:"videoId"` PlaylistID string `json:"playlistId"` Author string `json:"author"` IndexID string `json:"indexId"` ViewCountText string `json:"viewCountText"` PublishedText string `json:"publishedText"` Duration string `json:"duration"` Description string `json:"description"` VideoCount int64 `json:"videoCount"` SubCount int `json:"subCount"` LengthSeconds int64 `json:"lengthSeconds"` LiveNow bool `json:"liveNow"` } // SuggestData stores search suggestions. type SuggestData struct { Query string `json:"query"` Suggestions []string `json:"suggestions"` } // Search retrieves search results according to the provided query. func Search(stype, text string, parameters map[string]string, page int, ucid ...string) ([]SearchData, int, error) { var newpg int var data []SearchData client.Cancel() for newpg = page + 1; newpg <= page+2; newpg++ { query := "?q=" + url.QueryEscape(text) + searchField + "&page=" + strconv.Itoa(newpg) if stype == "channel" && ucid != nil { query = "channels/search/" + ucid[0] + query } else { query = "search" + query + "&type=" + stype } for param, val := range parameters { if val == "" { continue } query += "&" + param + "=" + val } res, err := client.Fetch(client.Ctx(), query) if err != nil { return nil, newpg, err } s := []SearchData{} err = resolver.DecodeJSONReader(res.Body, &s) if err != nil { return nil, newpg, err } data = append(data, s...) res.Body.Close() } return data, newpg, nil } // SearchSuggestions retrieves search suggestions. func SearchSuggestions(text string) (SuggestData, error) { var data SuggestData client.Cancel() query := "search/suggestions?q=" + url.QueryEscape(text) res, err := client.Fetch(client.Ctx(), query) if err != nil { return SuggestData{}, err } defer res.Body.Close() err = resolver.DecodeJSONReader(res.Body, &data) if err != nil { return SuggestData{}, err } return data, nil } invidtui-0.3.7/invidious/subscriptions.go000066400000000000000000000021401454311651000206200ustar00rootroot00000000000000package invidious import ( "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/resolver" ) const subFields = "?fields=author,authorId,error" // SubscriptionData stores information about the user's subscriptions. type SubscriptionData []struct { Author string `json:"author"` AuthorID string `json:"authorId"` } // Subscriptions retrieves the user's subscriptions. func Subscriptions() (SubscriptionData, error) { var data SubscriptionData res, err := client.Fetch(client.Ctx(), "auth/subscriptions"+subFields, client.Token()) if err != nil { return SubscriptionData{}, err } defer res.Body.Close() err = resolver.DecodeJSONReader(res.Body, &data) if err != nil { return SubscriptionData{}, err } return data, nil } // AddSubscription adds a channel to the user's subscriptions. func AddSubscription(id string) error { _, err := client.Send("auth/subscriptions/"+id, "", client.Token()) return err } // RemoveSubscription removes a user's subscription. func RemoveSubscription(id string) error { _, err := client.Remove("auth/subscriptions/"+id, client.Token()) return err } invidtui-0.3.7/invidious/video.go000066400000000000000000000222401454311651000170220ustar00rootroot00000000000000package invidious import ( "context" "fmt" "net/http" "strconv" "strings" "time" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/cmd" "github.com/darkhz/invidtui/resolver" "github.com/darkhz/invidtui/utils" "github.com/etherlabsio/go-m3u8/m3u8" ) const ( videoFields = "?fields=title,videoId,author,authorId,hlsUrl,publishedText,lengthSeconds,formatStreams,adaptiveFormats,videoThumbnails,liveNow,viewCount,likeCount,subCountText,description,error&hl=en" videoFormatFields = "?fields=formatStreams,adaptiveFormats,error" videoHlsFields = "?fields=hlsUrl,error" ) // VideoData stores information about a video. type VideoData struct { Title string `json:"title"` Author string `json:"author"` AuthorID string `json:"authorId"` VideoID string `json:"videoId"` HlsURL string `json:"hlsUrl"` LengthSeconds int64 `json:"lengthSeconds"` LiveNow bool `json:"liveNow"` ViewCount int `json:"viewCount"` LikeCount int `json:"likeCount"` PublishedText string `json:"publishedText"` SubCountText string `json:"subCountText"` Description string `json:"description"` Thumbnails []VideoThumbnails `json:"videoThumbnails"` FormatStreams []VideoFormat `json:"formatStreams"` AdaptiveFormats []VideoFormat `json:"adaptiveFormats"` MediaType string } // VideoFormat stores information about the video's format. type VideoFormat struct { Type string `json:"type"` URL string `json:"url"` Itag string `json:"itag"` Container string `json:"container"` Encoding string `json:"encoding"` Resolution string `json:"resolution,omitempty"` Bitrate int64 `json:"bitrate,string"` ContentLength int64 `json:"clen,string"` FPS int `json:"fps"` AudioSampleRate int `json:"audioSampleRate"` AudioChannels int `json:"audioChannels"` } // VideoThumbnails stores the video's thumbnails. type VideoThumbnails struct { Quality string `json:"quality"` URL string `json:"url"` Width int `json:"width"` Height int `json:"height"` } // Video retrieves a video. func Video(id string, ctx ...context.Context) (VideoData, error) { if ctx == nil { ctx = append(ctx, client.Ctx()) } return getVideo(ctx[0], id, videoFields) } // VideoThumbnail returns data to parse a video thumbnail. func VideoThumbnail(ctx context.Context, id, image string) (*http.Response, error) { res, err := client.Get(ctx, fmt.Sprintf("/vi/%s/%s", id, image)) if err != nil { return nil, err } return res, nil } // RenewVideoURI renews the video's media URIs. func RenewVideoURI(ctx context.Context, uri []string, video VideoData, audio bool) (VideoData, []string, error) { if uri != nil && video.LiveNow { if _, renew := CheckLiveURL(uri[0], audio); !renew { return video, uri, nil } } v, uris, err := getVideoURI(ctx, video, audio) if err != nil { return VideoData{}, uri, err } return v, uris, nil } // CheckLiveURL returns whether the provided live video's URL has expired or not. func CheckLiveURL(uri string, audio bool) (string, bool) { var id string renew := true // Split the uri parameters. uriSplit := strings.Split(uri, "/") for i, v := range uriSplit { if v == "expire" { // Return if the uri is not expired. exptime, err := strconv.ParseInt(uriSplit[i+1], 10, 64) if err == nil && time.Now().Unix() < exptime { renew = false continue } } if v == "id" { // Get the id value from the uri path. id = strings.Split(uriSplit[i+1], ".")[0] break } } return id, renew } // getVideo queries for and returns a video according to the provided parameters. func getVideo(ctx context.Context, id, param string) (VideoData, error) { var data VideoData res, err := client.Fetch(ctx, "videos/"+id+param) if err != nil { return VideoData{}, err } defer res.Body.Close() err = resolver.DecodeJSONReader(res.Body, &data) if err != nil { return VideoData{}, err } return data, nil } // getVideoURI returns the video's media URIs. func getVideoURI(ctx context.Context, video VideoData, audio bool) (VideoData, []string, error) { var uris []string var mediaURL, audioURL, videoURL string if video.FormatStreams == nil || video.AdaptiveFormats == nil { v, err := getVideo(ctx, video.VideoID, videoFormatFields) if err != nil { return VideoData{}, nil, err } video.AdaptiveFormats = v.AdaptiveFormats video.FormatStreams = v.FormatStreams } if video.LiveNow { audio = false videoURL, audioURL = getLiveVideo(ctx, video.VideoID, audio) } else { videoURL, audioURL = getVideoByItag(video, audio) } if audio && audioURL == "" { return VideoData{}, nil, fmt.Errorf("No audio URI") } else if !audio && videoURL == "" { return VideoData{}, nil, fmt.Errorf("No video URI") } if audio { mediaURL = audioURL } else { mediaURL = videoURL uris = append(uris, audioURL) } uris = append([]string{mediaURL}, uris...) return video, uris, nil } // getLiveVideo gets the hls playlist, parses and finds the appropriate live video stream. func getLiveVideo(ctx context.Context, id string, audio bool) (string, string) { var videoURL, audioURL string video, err := getVideo(ctx, id, videoHlsFields) if err != nil || video.HlsURL == "" { return "", "" } url, _ := utils.IsValidURL(video.HlsURL) res, err := client.Get(ctx, url.RequestURI()) if err != nil { return "", "" } defer res.Body.Close() pl, err := m3u8.Read(res.Body) if err != nil { return "", "" } for _, p := range pl.Playlists() { resolution := cmd.GetOptionValue("video-res") height := strconv.Itoa(p.Resolution.Height) + "p" // Since the retrieved HLS playlist is sorted in ascending order of resolutions, // for the audio stream, we grab the first stream (with the lowest quality), // and instruct mpv not to play video for the audio stream. For the video stream, // we grab the stream where the playlist entry's resolution and the required // resolution are equal. if audio || (!audio && height == resolution) { url, _ := utils.IsValidURL(p.URI) videoURL = "https://manifest.googlevideo.com" + url.RequestURI() break } } return videoURL, audioURL } // matchVideoResolution returns a URL that is associated with the video's format. func matchVideoResolution(video VideoData, urlType string) string { var uri string resolution := cmd.GetOptionValue("video-res") for _, format := range video.AdaptiveFormats { if len(format.Resolution) <= 0 { continue } switch urlType { case "url": if format.Resolution == resolution { return format.URL } uri = format.URL case "itag": if format.Resolution == resolution { return getLatestURL(video.VideoID, format.Itag) } uri = getLatestURL(video.VideoID, format.Itag) } } return uri } // getVideoByItag gets the appropriate itag of the video format, and // returns a video and audio url using getLatestURL(). func getVideoByItag(video VideoData, audio bool) (string, string) { var videoURL, audioURL string videoURL, audioURL = loopFormats( "itag", audio, video, func(v VideoData, f VideoFormat) string { video := getLatestURL(v.VideoID, f.Itag) return video }, func(v VideoData, f VideoFormat) string { return matchVideoResolution(v, "itag") }, ) return videoURL, audioURL } // loopFormats loops over a video's AdaptiveFormats data and gets the // audio/video URL according to the values returned by afunc/vfunc. func loopFormats( urlType string, audio bool, video VideoData, afunc, vfunc func(video VideoData, format VideoFormat) string, ) (string, string) { var ftype, videoURL, audioURL string // For videos, we loop through FormatStreams first and get the videoURL. // This works mainly for 720p, 360p and 144p video streams. if !audio && urlType != "itag" { for _, format := range video.FormatStreams { if format.Resolution == cmd.GetOptionValue("video-res") { videoURL = format.URL return videoURL, audioURL } } } // If the required resolution wasn't found in FormatStreams, we loop through // AdaptiveFormats and get a video of the required resolution, along with the // audio stream so that MPV can merge them and play. Or if only audio is required, // return a blank videoURL and a non-empty audioURL. for _, format := range video.AdaptiveFormats { v := strings.Split(format.Type, ";") p := strings.Split(v[0], "/") if (audio && audioURL != "") || (!audio && videoURL != "") { break } if ftype == "" { ftype = p[1] } if p[1] == ftype { if p[0] == "audio" { audioURL = afunc(video, format) } else if p[0] == "video" { videoURL = vfunc(video, format) } } } return videoURL, audioURL } // getLatestURL appends the latest_version query to the current client's host URL. // For example: https://invidious.snopyta.org/latest_version?id=mWDOxRWcoPE&itag=22&local=true func getLatestURL(id, itag string) string { var itagstr string host := client.Instance() idstr := "id=" + id if itag != "" { itagstr += "&itag=" + itag } return host + "/latest_version?" + idstr + itagstr + "&local=true" } invidtui-0.3.7/main.go000066400000000000000000000002351454311651000146270ustar00rootroot00000000000000package main import ( "github.com/darkhz/invidtui/cmd" "github.com/darkhz/invidtui/ui" ) func main() { cmd.Init() ui.SetupUI() cmd.SaveSettings() } invidtui-0.3.7/mediaplayer/000077500000000000000000000000001454311651000156505ustar00rootroot00000000000000invidtui-0.3.7/mediaplayer/mpv.go000066400000000000000000000174711454311651000170130ustar00rootroot00000000000000package mediaplayer import ( "fmt" "os" "os/exec" "strconv" "strings" "time" "github.com/darkhz/invidtui/resolver" "github.com/darkhz/mpvipc" ) // MPV describes the mpv player. type MPV struct { socket string *mpvipc.Connection } var mpv MPV // Init initializes and sets up MPV. func (m *MPV) Init(execpath, ytdlpath, numretries, useragent, socket string) error { if err := m.connect( execpath, ytdlpath, numretries, useragent, socket, ); err != nil { return err } go m.eventListener() m.Call("keybind", "q", "") m.Call("keybind", "Ctrl+q", "") m.Call("keybind", "Shift+q", "") return nil } // Exit tells MPV to exit. func (m *MPV) Exit() { m.Call("quit") m.Connection.Close() os.Remove(m.socket) } // Exited returns whether MPV has exited or not. func (m *MPV) Exited() bool { return m.Connection == nil || m.Connection.IsClosed() } // SendQuit sends a quit signal to the provided socket. func (m *MPV) SendQuit(socket string) { conn := mpvipc.NewConnection(socket) if err := conn.Open(); err != nil { return } conn.Call("quit") time.Sleep(1 * time.Second) } // LoadFile loads the provided files into MPV. When more than one file is provided, // the first file is treated as a video stream and the second file is attached as an audio stream. func (m *MPV) LoadFile(title string, duration int64, audio bool, files ...string) error { if files == nil { return fmt.Errorf("MPV: Unable to load empty fileset") } if audio { m.Call("set_property", "video", "0") } options := []string{} if duration > 0 { options = append(options, "length="+strconv.FormatInt(duration, 10)) } if len(files) == 2 { options = append(options, "audio-file="+files[1]) } _, err := m.Call("loadfile", files[0], "replace", strings.Join(options, ",")) if err != nil { return fmt.Errorf("MPV: Unable to load %s", title) } if !audio { m.Call("set_property", "video", "1") } return nil } // Play start the playback. func (m *MPV) Play() { m.Set("pause", "no") } // Stop stops the playback. func (m *MPV) Stop() { m.Call("stop") } // SeekForward seeks the track forward by 1s. func (m *MPV) SeekForward() { m.Call("seek", 1) } // SeekBackward seeks the track backward by 1s. func (m *MPV) SeekBackward() { m.Call("seek", -1) } // Position returns the seek position. func (m *MPV) Position() int64 { var position float64 timepos, err := m.Get("playback-time") if err == nil { m.store(timepos, &position) } return int64(position) } // Duration returns the total duration of the track. func (m *MPV) Duration() int64 { var duration float64 dur, err := m.Get("duration") if err != nil { var sdur string dur, err = m.Get("options/length") if err != nil { return 0 } m.store(dur, &sdur) time, err := strconv.ParseInt(sdur, 10, 64) if err != nil { return 0 } return time } m.store(dur, &duration) return int64(duration) } // Paused returns whether playback is paused or not. func (m *MPV) Paused() bool { var paused bool pause, err := m.Get("pause") if err == nil { m.store(pause, &paused) } return paused } // TogglePaused toggles pausing the playback. func (m *MPV) TogglePaused() { if m.Finished() && m.Paused() { m.Call("seek", 0, "absolute-percent") } m.Call("cycle", "pause") } // Muted returns whether playback is muted. func (m *MPV) Muted() bool { var muted bool mute, err := m.Get("mute") if err == nil { m.store(mute, &muted) } return muted } // ToggleMuted toggles muting of the playback. func (m *MPV) ToggleMuted() { m.Call("cycle", "mute") } // SetLoopMode sets the loop mode. func (m *MPV) SetLoopMode(mode RepeatMode) { switch mode { case RepeatModeOff: m.Set("loop-file", "no") m.Set("loop-playlist", "no") case RepeatModeFile: m.Set("loop-file", "yes") m.Set("loop-playlist", "no") case RepeatModePlaylist: m.Set("loop-file", "no") m.Set("loop-playlist", "yes") } } // Idle returns if the player is idle. func (m *MPV) Idle() bool { var idle bool ci, err := m.Get("core-idle") if err == nil { m.store(ci, &idle) } return idle } // Finished returns if the playback has finished. func (m *MPV) Finished() bool { var finished bool eof, err := m.Get("eof-reached") if err == nil { m.store(eof, &finished) } return finished } // Buffering returns if the player is buffering. func (m *MPV) Buffering() bool { var buffering bool buf, err := m.Get("paused-for-cache") if err == nil { m.store(buf, &buffering) } return buffering } // BufferPercentage returns the cache buffered percentage. func (m *MPV) BufferPercentage() int { var bufpercent int if !m.Buffering() { return -1 } pct, err := m.Get("cache-buffering-state") if err == nil { m.store(pct, &bufpercent) } return bufpercent } // Volume returns the volume. func (m *MPV) Volume() int { var volume float64 vol, err := m.Get("volume") if err != nil { return -1 } m.store(vol, &volume) return int(volume) } // VolumeIncrease increments the volume by 1. func (m *MPV) VolumeIncrease() { vol := m.Volume() if vol == -1 { return } m.Set("volume", vol+1) } // VolumeDecrease decreases the volume by 1. func (m *MPV) VolumeDecrease() { vol := m.Volume() if vol == -1 { return } m.Set("volume", vol-1) } // WaitClosed waits for MPV to exit. func (m *MPV) WaitClosed() { m.Connection.WaitUntilClosed() } // Call send a command to MPV. func (m *MPV) Call(args ...interface{}) (interface{}, error) { if m.Exited() { return nil, fmt.Errorf("MPV: Connection closed") } return m.Connection.Call(args...) } // Get gets a property from the mpv instance. func (m *MPV) Get(prop string) (interface{}, error) { if m.Exited() { return nil, fmt.Errorf("MPV: Connection closed") } return m.Connection.Get(prop) } // Set sets a property in the mpv instance. func (m *MPV) Set(prop string, value interface{}) error { if m.Exited() { return fmt.Errorf("MPV: Connection closed") } return m.Connection.Set(prop, value) } // connect launches MPV and starts a new connection via the provided socket. func (m *MPV) connect(mpvpath, ytdlpath, numretries, useragent, socket string) error { command := exec.Command( mpvpath, "--idle", "--keep-open", "--no-terminal", "--really-quiet", "--no-input-terminal", "--user-agent="+useragent, "--input-ipc-server="+socket, "--script-opts=ytdl_hook-ytdl_path="+ytdlpath, ) if err := command.Start(); err != nil { return fmt.Errorf("MPV: Could not start") } conn := mpvipc.NewConnection(socket) retries, _ := strconv.Atoi(numretries) for i := 0; i <= retries; i++ { err := conn.Open() if err != nil { time.Sleep(1 * time.Second) continue } m.socket = socket m.Connection = conn return nil } return fmt.Errorf("MPV: Could not connect to socket") } // store applies the property value into the given data container. func (m *MPV) store(prop, apply interface{}) { var data []byte err := resolver.EncodeSimpleBytes(&data, prop) if err == nil { resolver.DecodeSimpleBytes(data, apply) } } // eventListener listens for MPV events. // //gocyclo:ignore func (m *MPV) eventListener() { events, stopListening := m.Connection.NewEventListener() defer func() { stopListening <- struct{}{} }() m.Call("observe_property", 1, "eof-reached") for event := range events { mediaEvent := EventNone if event.ID == 1 { if eof, ok := event.Data.(bool); ok { mediaEvent = EventEnd if !eof { mediaEvent = EventInProgress } } } switch event.Name { case "start-file": m.Set("pause", "yes") m.Set("pause", "no") mediaEvent = EventStart case "end-file": if event.Reason == "eof" { mediaEvent = EventEnd } if len(event.ExtraData) > 0 { var errorText string m.store(event.ExtraData["file_error"], &errorText) if errorText != "" { mediaEvent = EventError } } case "file-loaded": mediaEvent = EventInProgress } EventHandler(mediaEvent) } } invidtui-0.3.7/mediaplayer/player.go000066400000000000000000000037001454311651000174730ustar00rootroot00000000000000package mediaplayer import "sync" // MediaPlayer describes a media player. type MediaPlayer interface { Init(execpath, ytdlpath, numretries, useragent, socket string) error Exit() Exited() bool SendQuit(socket string) LoadFile(title string, duration int64, liveaudio bool, files ...string) error Play() Stop() SeekForward() SeekBackward() Position() int64 Duration() int64 Paused() bool TogglePaused() Muted() bool ToggleMuted() SetLoopMode(mode RepeatMode) Idle() bool Finished() bool Buffering() bool BufferPercentage() int Volume() int VolumeIncrease() VolumeDecrease() WaitClosed() Call(args ...interface{}) (interface{}, error) Get(prop string) (interface{}, error) Set(prop string, value interface{}) error } // MediaPlayerSettings stores the media player's settings. type MediaPlayerSettings struct { current string handler func(e MediaEvent) mutex sync.Mutex } type MediaEvent int const ( EventNone MediaEvent = iota EventEnd EventStart EventInProgress EventError ) type RepeatMode int const ( RepeatModeOff RepeatMode = iota RepeatModeFile RepeatModePlaylist ) var ( settings MediaPlayerSettings players = map[string]MediaPlayer{ "mpv": &mpv, } ) // Init launches the provided player. func Init(player, execpath, ytdlpath, numretries, useragent, socket string) error { settings.current = player settings.handler = func(e MediaEvent) {} return players[player].Init( execpath, ytdlpath, numretries, useragent, socket, ) } // EventHandler sends a media event to the preset handler. func EventHandler(event MediaEvent) { settings.mutex.Lock() h := settings.handler settings.mutex.Unlock() h(event) } // SetEventHandler sets the media event handler. func SetEventHandler(handler func(e MediaEvent)) { settings.mutex.Lock() defer settings.mutex.Unlock() settings.handler = handler } // Player returns the currently selected player. func Player() MediaPlayer { return players[settings.current] } invidtui-0.3.7/platform/000077500000000000000000000000001454311651000152005ustar00rootroot00000000000000invidtui-0.3.7/platform/socket.go000066400000000000000000000002161454311651000170160ustar00rootroot00000000000000//go:build !windows // +build !windows package platform // Socket returns the socket path. func Socket(sock string) string { return sock } invidtui-0.3.7/platform/socket_windows.go000066400000000000000000000002421454311651000205670ustar00rootroot00000000000000//go:build windows // +build windows package platform // Socket returns the socket path. func Socket(sock string) string { return `\\.\pipe\invidtui-socket` } invidtui-0.3.7/platform/suspend.go000066400000000000000000000004011454311651000172030ustar00rootroot00000000000000//go:build !windows // +build !windows package platform import ( "syscall" "github.com/gdamore/tcell/v2" ) // Suspend suspends the application. func Suspend(t tcell.Screen) { t.Suspend() syscall.Kill(syscall.Getpid(), syscall.SIGSTOP) t.Resume() } invidtui-0.3.7/platform/suspend_windows.go000066400000000000000000000002421454311651000207600ustar00rootroot00000000000000//go:build windows // +build windows package platform import "github.com/gdamore/tcell/v2" // Suspend is disabled in Windows. func Suspend(t tcell.Screen) { } invidtui-0.3.7/resolver/000077500000000000000000000000001454311651000152155ustar00rootroot00000000000000invidtui-0.3.7/resolver/resolver.go000066400000000000000000000043001454311651000174020ustar00rootroot00000000000000package resolver import ( "io" "sync" "github.com/ugorji/go/codec" ) // Resolver describes an encoder/decoder handler. type Resolver struct { jsonDecoder *codec.Decoder jsonEncoder *codec.Encoder jsonHandle sync.Mutex simpleDecoder *codec.Decoder simpleEncoder *codec.Encoder simpleHandle sync.Mutex setupHandler sync.Mutex init bool } var resolver Resolver // setup sets up the resolver. func (r *Resolver) setup() { r.setupHandler.Lock() defer r.setupHandler.Unlock() if resolver.init { return } r.jsonDecoder = codec.NewDecoder(nil, &codec.JsonHandle{}) r.jsonEncoder = codec.NewEncoder(nil, &codec.JsonHandle{}) r.simpleDecoder = codec.NewDecoder(nil, &codec.JsonHandle{}) r.simpleEncoder = codec.NewEncoder(nil, &codec.JsonHandle{}) r.init = true } // DecodeJSONReader decodes JSON data from a Reader. func DecodeJSONReader(reader io.Reader, apply interface{}) error { resolver.setup() resolver.jsonHandle.Lock() defer resolver.jsonHandle.Unlock() resolver.jsonDecoder.Reset(reader) return resolver.jsonDecoder.Decode(apply) } // DecodeJSONBytes decodes JSON data from a byte array. func DecodeJSONBytes(data []byte, apply interface{}) error { resolver.setup() resolver.jsonHandle.Lock() defer resolver.jsonHandle.Unlock() resolver.jsonDecoder.ResetBytes(data) return resolver.jsonDecoder.Decode(apply) } // DecodeSimpleReader decodes data from a Reader. func DecodeSimpleReader(reader io.Reader, apply interface{}) error { resolver.setup() resolver.simpleHandle.Lock() defer resolver.simpleHandle.Unlock() resolver.simpleDecoder.Reset(reader) return resolver.simpleDecoder.Decode(apply) } // DecodeSimpleBytes decodes data from a byte array. func DecodeSimpleBytes(data []byte, apply interface{}) error { resolver.setup() resolver.simpleHandle.Lock() defer resolver.simpleHandle.Unlock() resolver.simpleDecoder.ResetBytes(data) return resolver.simpleDecoder.Decode(apply) } // EncodeJSONBytes encodes data from a byte array. func EncodeSimpleBytes(data *[]byte, apply interface{}) error { resolver.setup() resolver.simpleHandle.Lock() defer resolver.simpleHandle.Unlock() resolver.simpleEncoder.ResetBytes(data) return resolver.simpleEncoder.Encode(apply) } invidtui-0.3.7/ui/000077500000000000000000000000001454311651000137715ustar00rootroot00000000000000invidtui-0.3.7/ui/app/000077500000000000000000000000001454311651000145515ustar00rootroot00000000000000invidtui-0.3.7/ui/app/application.go000066400000000000000000000064361454311651000174140ustar00rootroot00000000000000package app import ( "context" "sync" "github.com/darkhz/invidtui/platform" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // Application describes the layout of the app. type Application struct { MenuLayout *tview.Flex Menu, Tabs *tview.TextView Area *tview.Pages Pages *tview.Pages Layout, Region *tview.Flex Status Status FileBrowser FileBrowser SelectedStyle tcell.Style ColumnStyle tcell.Style Suspend bool Closed context.Context Exit context.CancelFunc resize func(screen tcell.Screen) lock sync.Mutex *tview.Application } // UI stores the application data. var UI Application // Setup sets up the application func Setup() { box := tview.NewBox(). SetBackgroundColor(tcell.ColorDefault) UI.Status.Setup() UI.SelectedStyle = tcell.Style{}. Foreground(tcell.ColorBlue). Background(tcell.ColorWhite). Attributes(tcell.AttrBold) UI.ColumnStyle = tcell.Style{}. Attributes(tcell.AttrBold) UI.Menu, UI.Tabs = tview.NewTextView(), tview.NewTextView() UI.Menu.SetWrap(false) UI.Menu.SetRegions(true) UI.Tabs.SetWrap(false) UI.Tabs.SetRegions(true) UI.Tabs.SetDynamicColors(true) UI.Menu.SetDynamicColors(true) UI.Tabs.SetTextAlign(tview.AlignRight) UI.Menu.SetBackgroundColor(tcell.ColorDefault) UI.Tabs.SetBackgroundColor(tcell.ColorDefault) UI.Menu.SetHighlightedFunc(MenuHighlightHandler) UI.Menu.SetInputCapture(MenuKeybindings) UI.MenuLayout = tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(UI.Menu, 0, 1, false). AddItem(UI.Tabs, 0, 1, false) UI.MenuLayout.SetBackgroundColor(tcell.ColorDefault) UI.Pages = tview.NewPages() UI.Pages.SetChangedFunc(func() { MenuExit() }) UI.Region = tview.NewFlex(). AddItem(UI.Pages, 0, 1, true) UI.Layout = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(UI.MenuLayout, 1, 0, false). AddItem(box, 1, 0, false). AddItem(UI.Region, 0, 10, false). AddItem(box, 1, 0, false). AddItem(UI.Status.Pages, 1, 0, false) UI.Layout.SetBackgroundColor(tcell.ColorDefault) UI.Area = tview.NewPages() UI.Area.AddPage("ui", UI.Layout, true, true) UI.Area.SetChangedFunc(func() { pg, _ := UI.Area.GetFrontPage() if pg == "ui" || pg == "menu" { return } MenuExit() }) UI.Closed, UI.Exit = context.WithCancel(context.Background()) UI.Application = tview.NewApplication() UI.SetAfterDrawFunc(func(screen tcell.Screen) { UI.resize(screen) suspend(screen) }) } // SetPrimaryFocus sets the focus to the appropriate primitive. func SetPrimaryFocus() { if pg, _ := UI.Status.GetFrontPage(); pg == "input" { UI.SetFocus(UI.Status.InputField) return } if len(modals) > 0 { UI.SetFocus(modals[len(modals)-1].Flex) return } UI.SetFocus(UI.Pages) } // SetResizeHandler sets the resize handler for the app. func SetResizeHandler(resize func(screen tcell.Screen)) { UI.resize = resize } // SetGlobalKeybindings sets the keybindings for the app. func SetGlobalKeybindings(kb func(event *tcell.EventKey) *tcell.EventKey) { UI.SetInputCapture(kb) } // Stop stops the application. func Stop(skip ...struct{}) { UI.lock.Lock() defer UI.lock.Unlock() if skip == nil { UI.Exit() } UI.Status.Cancel() UI.Stop() } // suspend suspends the app. func suspend(t tcell.Screen) { if !UI.Suspend { return } platform.Suspend(t) UI.Suspend = false } invidtui-0.3.7/ui/app/filebrowser.go000066400000000000000000000241331454311651000174260ustar00rootroot00000000000000package app import ( "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "sync" "github.com/darkhz/invidtui/cmd" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" "github.com/mitchellh/go-homedir" "golang.org/x/sync/semaphore" ) // FileBrowser describes the layout of a file browser. type FileBrowser struct { init, hidden, dironly bool prevDir, currentPath, prompt string dofunc func(text string) modal *Modal flex *tview.Flex table *tview.Table title *tview.TextView input *tview.InputField lock *semaphore.Weighted mutex sync.Mutex } // FileBrowserOptions describes the file browser options. type FileBrowserOptions struct { ShowDirOnly bool SetDir string } // setup sets up the file browser. func (f *FileBrowser) setup() { if f.init { return } f.title = tview.NewTextView() f.title.SetDynamicColors(true) f.title.SetTextAlign(tview.AlignCenter) f.title.SetBackgroundColor(tcell.ColorDefault) f.table = tview.NewTable() f.table.SetSelectorWrap(true) f.table.SetInputCapture(f.Keybindings) f.table.SetBackgroundColor(tcell.ColorDefault) f.table.SetSelectionChangedFunc(f.selectorHandler) f.input = tview.NewInputField() f.input.SetLabel("[::b]File: ") f.input.SetInputCapture(f.inputFunc) f.input.SetLabelColor(tcell.ColorWhite) f.input.SetBackgroundColor(tcell.ColorDefault) f.input.SetFieldBackgroundColor(tcell.ColorDefault) f.input.SetFocusFunc(func() { SetContextMenu(cmd.KeyContextFiles, f.input) }) f.flex = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(f.title, 1, 0, false). AddItem(f.table, 0, 1, false). AddItem(HorizontalLine(), 1, 0, false). AddItem(f.input, 1, 0, true) f.modal = NewModal("Files", "Browse", f.flex, 60, 100) f.lock = semaphore.NewWeighted(1) f.hidden = true f.init = true } // Show displays the file browser. func (f *FileBrowser) Show(prompt string, dofunc func(text string), options ...FileBrowserOptions) { f.setup() f.dofunc = dofunc f.dironly = false f.prompt = "[::b]" + prompt + " " f.input.SetLabel(f.prompt) if options != nil { f.dironly = options[0].ShowDirOnly if dir := options[0].SetDir; dir != "" { f.currentPath = dir } } f.modal.Show(false) go f.cd("", false, false) } // Hide hides the file browser. func (f *FileBrowser) Hide() { f.modal.Exit(false) } // Query displays a confirmation message within the file browser. func (f *FileBrowser) Query( prompt string, validate func(text string, reply chan string), max ...int, ) string { reply := make(chan string) var acceptFunc func(text string, ch rune) bool if max != nil { acceptFunc = tview.InputFieldMaxLength(max[0]) } UI.QueueUpdateDraw(func() { f.input.SetText("") f.input.SetLabel(prompt + " ") f.input.SetAcceptanceFunc(acceptFunc) f.input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter: go validate(f.input.GetText(), reply) case tcell.KeyEscape: select { case reply <- "": default: } } return event }) }) response := <-reply UI.QueueUpdateDraw(func() { row, _ := f.table.GetSelection() f.table.Select(row, 0) f.input.SetLabel(f.prompt) f.input.SetInputCapture(f.inputFunc) }) return response } // SaveFile saves the generated entries into a file. func (f *FileBrowser) SaveFile( file string, entriesFunc func(flags int, appendToFile bool) (string, int, error), ) { flags, appendToFile, confirm, exist := f.confirmOverwrite(file) if exist && !confirm { return } f.Hide() entries, newflags, err := entriesFunc(flags, appendToFile) if err != nil { ShowError(err) return } saveFile, err := os.OpenFile(file, newflags, 0664) if err != nil { ShowError(fmt.Errorf("FileBrowser: Unable to open file")) return } _, err = saveFile.WriteString(entries) if err != nil { ShowError(fmt.Errorf("FileBrowser: Unable to save file")) return } message := " saved in " if appendToFile { message = " appended to " } ShowInfo("Contents"+message+file, false) } // Keybindings define the keybindings for the file browser. func (f *FileBrowser) Keybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event, cmd.KeyContextFiles) { case cmd.KeyFilebrowserDirForward: sel, _ := f.table.GetSelection() cell := f.table.GetCell(sel, 0) go f.cd(filepath.Clean(cell.Text), true, false) case cmd.KeyFilebrowserDirBack: go f.cd("", false, true) case cmd.KeyFilebrowserToggleHidden: f.hiddenStatus(struct{}{}) go f.cd("", false, false) case cmd.KeyFilebrowserNewFolder: go f.newFolder() case cmd.KeyFilebrowserRename: go f.renameItem() return nil } return event } // inputFunc defines the keybindings for the file browser's inputbox. func (f *FileBrowser) inputFunc(e *tcell.EventKey) *tcell.EventKey { var toggle bool switch cmd.KeyOperation(e, cmd.KeyContextFiles) { case cmd.KeyFilebrowserToggleHidden: toggle = true case cmd.KeyFilebrowserSelect: text := f.input.GetText() if text == "" { goto Event } go f.dofunc(filepath.Join(f.currentPath, text)) case cmd.KeyClose: f.modal.Exit(false) goto Event } f.table.InputHandler()(tcell.NewEventKey(e.Key(), ' ', e.Modifiers()), nil) Event: if toggle { e = nil } return e } // selectorHandler checks whether the selected item is a file, // and automatically appends the filename to the input box. func (f *FileBrowser) selectorHandler(row, col int) { sel, _ := f.table.GetSelection() cell := f.table.GetCell(sel, 0) if !f.dironly && strings.Contains(cell.Text, string(os.PathSeparator)) { f.input.SetText("") return } f.input.SetText(cell.Text) } // cd changes the directory. func (f *FileBrowser) cd(entry string, cdFwd bool, cdBack bool) { var testPath string if !f.lock.TryAcquire(1) { return } defer f.lock.Release(1) if f.currentPath == "" { var err error f.currentPath, err = homedir.Dir() if err != nil { ShowError(err) return } } testPath = f.currentPath switch { case cdFwd: testPath = utils.TrimPath(testPath, false) testPath = filepath.Join(testPath, entry) case cdBack: f.prevDir = filepath.Base(testPath) testPath = utils.TrimPath(testPath, cdBack) } dlist, listed := f.list(filepath.FromSlash(testPath)) if !listed { return } sort.Slice(dlist, func(i, j int) bool { if dlist[i].IsDir() != dlist[j].IsDir() { return dlist[i].IsDir() } return dlist[i].Name() < dlist[j].Name() }) f.currentPath = testPath f.render(dlist, cdBack) } // list lists a directory's contents. func (f *FileBrowser) list(testPath string) ([]fs.DirEntry, bool) { var dlist []fs.DirEntry stat, err := os.Lstat(testPath) if err != nil { return nil, false } if !stat.IsDir() { return nil, false } file, err := os.Open(testPath) if err != nil { ShowError(err) return nil, false } defer file.Close() list, err := os.ReadDir(testPath) if err != nil { if err.Error() != "EOF" { ShowError(err) } return nil, false } for _, entry := range list { if f.hiddenStatus() && strings.HasPrefix(entry.Name(), ".") { continue } if f.dironly && !entry.IsDir() { continue } dlist = append(dlist, entry) } return dlist, true } // render displays the contents of the directory on // the filebrowser popup. func (f *FileBrowser) render(dlist []fs.DirEntry, cdBack bool) { UI.QueueUpdateDraw(func() { var pos int f.table.Clear() f.table.SetSelectable(false, false) for row, entry := range dlist { var color tcell.Color name := entry.Name() if entry.IsDir() { if cdBack && name == f.prevDir { pos = row } name += string(os.PathSeparator) color = tcell.ColorBlue } else { color = tcell.ColorWhite } f.table.SetCell(row, 0, tview.NewTableCell(name). SetTextColor(color)) } f.table.ScrollToBeginning() f.table.SetSelectable(true, false) f.table.Select(pos, 0) f.title.SetText("[::bu]" + f.currentPath) ResizeModal() }) } // confirmOverwrite displays an overwrite confirmation message // within the file browser. This is triggered if the selected file // in the file browser already exists and has entries in it. func (f *FileBrowser) confirmOverwrite(file string) (int, bool, bool, bool) { var appendToFile bool flags := os.O_CREATE | os.O_WRONLY if _, err := os.Stat(file); err != nil { return flags, false, false, false } reply := f.Query("Overwrite file (y/n/a)?", f.validateConfirm, 1) switch reply { case "y": flags |= os.O_TRUNC case "a": flags |= os.O_APPEND appendToFile = true case "n": break default: reply = "" } return flags, appendToFile, reply != "", true } // newFolder prompts for a name and creates a directory. func (f *FileBrowser) newFolder() { name := f.Query("[::b]Folder name:", f.validateText) if name == "" { return } if err := os.Mkdir(filepath.Join(f.currentPath, name), os.ModePerm); err != nil { ShowError(fmt.Errorf("Filebrowser: Could not create directory %s", name)) return } go f.cd("", false, false) } // renameItem prompts for a name and renames the currently selected entry. func (f *FileBrowser) renameItem() { name := f.Query("[::b]Rename to:", f.validateText) if name == "" { return } row, _ := f.table.GetSelection() oldname := f.table.GetCell(row, 0).Text if err := os.Rename(filepath.Join(f.currentPath, oldname), filepath.Join(f.currentPath, name)); err != nil { ShowError(fmt.Errorf("Filebrowser: Could not rename %s to %s", oldname, name)) return } go f.cd("", false, false) } // validateConfirm validates the overwrite confirmation reply. func (f *FileBrowser) validateConfirm(text string, reply chan string) { for _, option := range []string{"y", "n", "a"} { if text == option { select { case reply <- text: default: } break } } } // validateText validates whether the text is empty or not. func (f *FileBrowser) validateText(text string, reply chan string) { if text != "" { select { case reply <- text: default: } } } // hiddenStatus returns whether hidden files are displayed or not. func (f *FileBrowser) hiddenStatus(toggle ...struct{}) bool { f.mutex.Lock() defer f.mutex.Unlock() if toggle != nil { f.hidden = !f.hidden } return f.hidden } invidtui-0.3.7/ui/app/menu.go000066400000000000000000000114311454311651000160440ustar00rootroot00000000000000package app import ( "fmt" "strings" "github.com/darkhz/invidtui/cmd" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // MenuData stores the menu items and handlers. type MenuData struct { Visible map[cmd.Key]func(menuType string) bool Items map[cmd.KeyContext][]cmd.Key } // MenuArea stores the menu modal and the current context menu. type MenuArea struct { context cmd.KeyContext modal *Modal data *MenuData focus tview.Primitive } var menuArea MenuArea // InitMenu initializes the menu. func InitMenu(data *MenuData) { menuArea.data = data AddMenu("App") AddMenu("Player") } // AddMenu adds a menu to the menubar. func AddMenu(menuType cmd.KeyContext) { _, ok := menuArea.data.Items[menuType] if !ok { return } text := UI.Menu.GetText(false) if text == "" { text = string('\u2261') } UI.Menu.SetText(menuFormat(text, string(menuType), string(menuType))) } // MenuExit closes the menu. func MenuExit() { UI.Menu.Highlight("") menuArea.modal.Exit(false) } // SetContextMenu sets the context menu. func SetContextMenu(menuType cmd.KeyContext, item tview.Primitive) { if menuArea.context == menuType && menuArea.focus == item { return } menuArea.context = menuType text := UI.Menu.GetText(false) if text == "" { text = string('\u2261') } regions := strings.Split(text, " ") for i, region := range regions { if strings.Contains(region, "context-") { regions = regions[0:i] text = strings.Join(regions, " ") } } if _, ok := menuArea.data.Items[cmd.KeyContext(menuType)]; ok { text = menuFormat(text, "context-"+string(menuType), string(menuType)) } menuArea.focus = item UI.Menu.SetText(text) } // FocusMenu activates the menu bar. func FocusMenu() { if len(UI.Menu.GetHighlights()) > 0 { return } regions := UI.Menu.GetRegionIDs() if regions == nil { return } region := regions[0] for _, r := range regions { if strings.Contains(r, "context-") && !strings.Contains(r, "Start") { region = r break } } UI.Menu.Highlight(region) } // DrawMenu renders the menu. // //gocyclo:ignore func DrawMenu(x int, region string) { var skipped, width int if strings.Contains(region, "context-") { region = strings.Split(region, "-")[1] } menuItems, ok := menuArea.data.Items[cmd.KeyContext(region)] if !ok { return } modal := NewMenuModal("menu", x, 1) modal.Table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter: row, _ := modal.Table.GetSelection() ref := modal.Table.GetCell(row, 0).GetReference() if op, ok := ref.(*cmd.KeyData); ok { MenuKeybindings(event) if op.Kb.Key != tcell.KeyRune { op.Kb.Rune = rune(op.Kb.Key) } ev := tcell.NewEventKey(op.Kb.Key, op.Kb.Rune, op.Kb.Mod) UI.Application.GetInputCapture()(ev) if op.Global { break } if menuArea.focus != nil { menuArea.focus.InputHandler()(ev, nil) } } case tcell.KeyEscape, tcell.KeyTab: MenuKeybindings(event) } return event }) for row, item := range menuItems { if visible, ok := menuArea.data.Visible[item]; ok && !visible(region) { skipped++ continue } op := cmd.OperationData(item) keyname := cmd.KeyName(op.Kb) opwidth := len(op.Title) + len(keyname) + 10 if opwidth > width { width = opwidth } modal.Table.SetCell(row-skipped, 0, tview.NewTableCell(op.Title). SetExpansion(1). SetReference(op). SetAttributes(tcell.AttrBold), ) modal.Table.SetCell(row-skipped, 1, tview.NewTableCell(keyname). SetExpansion(1). SetAlign(tview.AlignRight), ) } modal.Width = width modal.Height = (len(menuItems) - skipped) + 2 if modal.Height > 10 { modal.Height = 10 } menuArea.modal = modal modal.Show(false) } // MenuHighlightHandler draws the menu based on which menu name is highlighted. func MenuHighlightHandler(added, removed, remaining []string) { if added == nil { return } for _, region := range UI.Menu.GetRegionIDs() { if region == added[0] { DrawMenu(UI.Menu.GetRegionStart(region), added[0]) break } } } // MenuKeybindings describes the menu keybindings. func MenuKeybindings(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter, tcell.KeyEscape: MenuExit() case tcell.KeyTab: var index int highlighted := UI.Menu.GetHighlights() if highlighted == nil { goto Event } regions := UI.Menu.GetRegionIDs() for i, region := range regions { if highlighted[0] == region { index = i break } } if index == len(regions)-1 { index = 0 } else { index++ } MenuExit() UI.Menu.Highlight(regions[index]) } Event: return event } // menuFormat returns the format for displaying menu names. func menuFormat(text, region, title string) string { return fmt.Sprintf("%s [\"%s\"][::b]%s[-:-:-][\"\"]", text, region, title) } invidtui-0.3.7/ui/app/modal.go000066400000000000000000000112051454311651000161730ustar00rootroot00000000000000package app import ( "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // Modal stores a layout to display a floating modal. type Modal struct { Name string Open bool Height, Width int attach, menu bool regionX, regionY, pageHeight, pageWidth int Flex *tview.Flex Table *tview.Table y *tview.Flex x *tview.Flex } var modals []*Modal // NewModal returns a modal. If a primitive is not provided, // a table is attach to it. func NewModal(name, title string, item tview.Primitive, height, width int) *Modal { var table *tview.Table modalTitle := tview.NewTextView() modalTitle.SetDynamicColors(true) modalTitle.SetText("[::bu]" + title) modalTitle.SetTextAlign(tview.AlignCenter) modalTitle.SetBackgroundColor(tcell.ColorDefault) if item == nil { table = tview.NewTable() table.SetSelectorWrap(true) table.SetSelectable(true, false) table.SetBackgroundColor(tcell.ColorDefault) item = table } flex := tview.NewFlex() flex.SetBorder(true) flex.SetDirection(tview.FlexRow) box := tview.NewBox() box.SetBackgroundColor(tcell.ColorDefault) flex.AddItem(modalTitle, 1, 0, false) flex.AddItem(box, 1, 0, false) flex.AddItem(item, 0, 1, true) flex.SetBackgroundColor(tcell.ColorDefault) return &Modal{ Name: name, Flex: flex, Table: table, Height: height, Width: width, } } // NewMenuModal returns a menu modal. func NewMenuModal(name string, regionX, regionY int) *Modal { table := tview.NewTable() table.SetBorder(true) table.SetSelectable(true, false) table.SetBackgroundColor(tcell.ColorDefault) flex := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(table, 0, 1, true) return &Modal{ Name: name, Table: table, Flex: flex, menu: true, regionX: regionX, regionY: regionY, } } // Show shows the modal. If attachToStatus is true, the modal will // attach to the top part of the status bar rather than float in the middle. func (m *Modal) Show(attachToStatus bool) { var x, y, xprop, xattach, yattach int if len(modals) > 0 && modals[len(modals)-1].Name == m.Name { return } switch { case m.menu: xprop = 1 x, y = m.regionX, m.regionY case attachToStatus: m.attach = true xattach, yattach = 1, 1 default: xattach = 1 } m.Open = true m.y = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(nil, y, yattach, false). AddItem(m.Flex, m.Height, 0, true). AddItem(nil, yattach, 0, false) m.x = tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(nil, x, xattach, false). AddItem(m.y, m.Width, 0, true). AddItem(nil, xprop, xattach, false) UI.Area.AddAndSwitchToPage(m.Name, m.x, true) for _, modal := range modals { UI.Area.ShowPage(modal.Name) } UI.Area.ShowPage("ui") UI.SetFocus(m.Flex) modals = append(modals, m) ResizeModal() } // Exit exits the modal. func (m *Modal) Exit(focusInput bool) { if m == nil { return } m.Open = false m.pageWidth = 0 m.pageHeight = 0 UI.Area.RemovePage(m.Name) for i, modal := range modals { if modal == m { modals[i] = modals[len(modals)-1] modals = modals[:len(modals)-1] break } } if focusInput { UI.SetFocus(UI.Status.InputField) return } SetPrimaryFocus() } // ResizeModal resizes the modal according to the current screen dimensions. // //gocyclo:ignore func ResizeModal() { var drawn bool for _, modal := range modals { _, _, pageWidth, pageHeight := UI.Region.GetInnerRect() _, _, _, mh := UI.MenuLayout.GetRect() if modal == nil || !modal.Open || (modal.pageHeight == pageHeight && modal.pageWidth == pageWidth) { continue } modal.pageHeight = pageHeight modal.pageWidth = pageWidth if modal.attach { pageHeight /= 2 } height := modal.Height width := modal.Width if height >= pageHeight { height = pageHeight } if width >= pageWidth { width = pageWidth } switch { case modal.attach: switch { case playerShown() && modal.y.GetItemCount() == 3: modal.y.AddItem(nil, 2, 0, false) case !playerShown() && modal.y.GetItemCount() > 3: modal.y.RemoveItemIndex(modal.y.GetItemCount() - 1) } modal.y.ResizeItem(modal.Flex, pageHeight, 0) modal.x.ResizeItem(modal.y, pageWidth, 0) default: var x, y int if modal.menu { x, y = modal.regionX, modal.regionY } else { x = (pageWidth - modal.Width) / 2 y = mh + 1 } modal.y.ResizeItem(modal.Flex, height, 0) modal.y.ResizeItem(nil, y, 0) modal.x.ResizeItem(modal.y, width, 0) modal.x.ResizeItem(nil, x, 0) } drawn = true } if drawn { go UI.Draw() } } // playerShown returns whether the player is shown or not. func playerShown() bool { return UI.Layout.GetItemCount() > 5 } invidtui-0.3.7/ui/app/status.go000066400000000000000000000113411454311651000164230ustar00rootroot00000000000000package app import ( "context" "errors" "strings" "time" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // Status describes the layout for a status bar type Status struct { Message *tview.TextView acceptMax int inputLabel string inputBoxFunc func(text string) inputChgFunc func(text string) defaultIFunc func(event *tcell.EventKey) *tcell.EventKey ctx context.Context Cancel context.CancelFunc msgchan chan message tag chan string *tview.Pages *tview.InputField } // message includes the text to be shown within the status bar, // and determines whether the message is to be shown persistently. type message struct { text string persist bool } // Setup sets up the status bar. func (s *Status) Setup() { s.Pages = tview.NewPages() s.Message = tview.NewTextView() s.Message.SetDynamicColors(true) s.Message.SetBackgroundColor(tcell.ColorDefault) s.InputField = tview.NewInputField() s.InputField.SetLabelColor(tcell.ColorWhite) s.InputField.SetBackgroundColor(tcell.ColorDefault) s.InputField.SetFieldBackgroundColor(tcell.ColorDefault) s.InputField.SetFocusFunc(s.inputFocus) s.InputField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter: text := s.InputField.GetText() if text == "" { return event } s.inputBoxFunc(text) fallthrough case tcell.KeyEscape: _, item := UI.Pages.GetFrontPage() UI.SetFocus(item) s.Pages.SwitchToPage("messages") } return event }) s.Pages.AddPage("input", s.InputField, true, true) s.Pages.AddPage("messages", s.Message, true, true) s.tag = make(chan string, 1) s.msgchan = make(chan message, 10) s.defaultIFunc = s.InputField.GetInputCapture() s.ctx, s.Cancel = context.WithCancel(context.Background()) go s.startStatus() } // InfoMessage sends an info message to the status bar. func (s *Status) InfoMessage(text string, persist bool) { select { case s.msgchan <- message{"[white::b]" + text, persist}: return default: } } // ErrorMessage sends an error message to the status bar. func (s *Status) ErrorMessage(err error) { if errors.Is(err, context.Canceled) { return } select { case s.msgchan <- message{"[red::b]" + err.Error(), false}: return default: } } // SetInput sets up the prompt and appropriate handlers // for the input area within the status bar. func (s *Status) SetInput(label string, max int, clearInput bool, dofunc func(text string), ifunc func(event *tcell.EventKey) *tcell.EventKey, chgfunc ...func(text string), ) { s.inputBoxFunc = dofunc if max > 0 { s.InputField.SetAcceptanceFunc(tview.InputFieldMaxLength(max)) } else { s.InputField.SetAcceptanceFunc(nil) } s.acceptMax = max if chgfunc != nil { s.inputChgFunc = chgfunc[0] } else { s.inputChgFunc = nil } s.InputField.SetChangedFunc(s.inputChgFunc) if clearInput { s.InputField.SetText("") } s.InputField.SetLabel("[::b]" + label + " ") if ifunc != nil { s.InputField.SetInputCapture(ifunc) } else { s.InputField.SetInputCapture(s.defaultIFunc) } UI.Status.Pages.SwitchToPage("input") UI.SetFocus(s.InputField) } // SetFocusFunc sets the function to be executed when the input is focused. func (s *Status) SetFocusFunc(focus ...func()) { if focus == nil { s.InputField.SetFocusFunc(s.inputFocus) return } s.InputField.SetFocusFunc(focus[0]) } // Tag sets a tag to the status bar. func (s *Status) Tag(tag string) { select { case s.tag <- tag: return default: } } // startStatus starts the message event loop func (s *Status) startStatus() { var tag, text, message string var cleared bool t := time.NewTicker(2 * time.Second) defer t.Stop() for { msgtext := "" select { case <-s.ctx.Done(): return case msg, ok := <-s.msgchan: if !ok { return } t.Reset(2 * time.Second) cleared = false message = msg.text if msg.persist { text = msg.text } if !msg.persist && text != "" { text = "" } msgtext = tag + message case t, ok := <-s.tag: if !ok { return } tag = t if tag != "" { tag += " " } m := message if text != "" { m = text } msgtext = tag + m case <-t.C: message = "" if cleared { continue } cleared = true msgtext = tag + text } go UI.QueueUpdateDraw(func() { s.Message.SetText(msgtext) }) } } func (s *Status) inputFocus() { label := s.InputField.GetLabel() if label != s.inputLabel { s.inputLabel = strings.TrimSpace(label) } } // ShowInfo shows an information message. func ShowInfo(text string, persist bool, print ...bool) { if print != nil && !print[0] { return } UI.Status.InfoMessage(text, persist) } // ShowError shows an error message. func ShowError(err error) { UI.Status.ErrorMessage(err) } invidtui-0.3.7/ui/app/tabs.go000066400000000000000000000032451454311651000160350ustar00rootroot00000000000000package app import "fmt" // Tab describes the layout for a tab. type Tab struct { Title, Selected string Info []TabInfo } // TabInfo stores the tab information. type TabInfo struct { ID, Title string } // SetTab sets the tab. func SetTab(tabInfo Tab) { if tabInfo.Title == "" { UI.Tabs.Clear() return } tab := "" for _, info := range tabInfo.Info { tab += fmt.Sprintf("[\"%s\"][darkcyan]%s[\"\"] ", info.ID, info.Title) } UI.Tabs.SetText(tab) SelectTab(tabInfo.Selected) } // SelectTab selects a tab. func SelectTab(tab string) { UI.Tabs.Highlight(tab) } // GetCurrentTab returns the currently selected tab. func GetCurrentTab() string { tab := UI.Tabs.GetHighlights() if tab == nil { return "" } return tab[0] } // SwitchTab handles the tab selection. // If reverse is set, the previous tab is selected and vice-versa. func SwitchTab(reverse bool, tabs ...Tab) string { var currentView int var selected string var regions []string if tabs != nil { selected = tabs[0].Selected for _, region := range tabs[0].Info { regions = append(regions, region.ID) } goto Selected } regions = UI.Tabs.GetRegionIDs() if len(regions) == 0 { return "" } if highlights := UI.Tabs.GetHighlights(); highlights != nil { selected = highlights[0] } else { return "" } Selected: for i, region := range regions { if region == selected { currentView = i } } if reverse { currentView-- } else { currentView++ } if currentView >= len(regions) { currentView = 0 } else if currentView < 0 { currentView = len(regions) - 1 } UI.Tabs.Highlight(regions[currentView]) UI.Tabs.ScrollToHighlight() return regions[currentView] } invidtui-0.3.7/ui/app/utils.go000066400000000000000000000052171454311651000162450ustar00rootroot00000000000000package app import ( "fmt" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // HorizontalLine returns a box with a thick horizontal line. func HorizontalLine() *tview.Box { return tview.NewBox(). SetBackgroundColor(tcell.ColorDefault). SetDrawFunc(func( screen tcell.Screen, x, y, width, height int) (int, int, int, int) { centerY := y + height/2 for cx := x; cx < x+width; cx++ { screen.SetContent( cx, centerY, tview.BoxDrawingsLightHorizontal, nil, tcell.StyleDefault.Foreground(tcell.ColorWhite), ) } return x + 1, centerY + 1, width - 2, height - (centerY + 1 - y) }) } // VerticalLine returns a box with a thick vertical line. func VerticalLine() *tview.Box { return tview.NewBox(). SetBackgroundColor(tcell.ColorDefault). SetDrawFunc(func( screen tcell.Screen, x, y, width, height int, ) (int, int, int, int) { for cy := y; cy < y+height; cy++ { screen.SetContent(x, cy, tview.BoxDrawingsLightVertical, nil, tcell.StyleDefault.Foreground(tcell.ColorWhite)) screen.SetContent(x+width-1, cy, tview.BoxDrawingsLightVertical, nil, tcell.StyleDefault.Foreground(tcell.ColorWhite)) } return x, y, width, height }) } // ModifyReference modifies the currently selected entry within the focused table. func ModifyReference(title string, add bool, info ...inv.SearchData) error { err := fmt.Errorf("Application: Cannot modify list entry") table := FocusedTable() if table == nil { return err } for i := 0; i < table.GetRowCount(); i++ { cell := table.GetCell(i, 0) if cell == nil { continue } ref := cell.GetReference() if ref == nil { continue } if info[0] == ref.(inv.SearchData) { if add { cell.SetText(title) cell.SetReference(info[1]) } else { table.RemoveRow(i) } break } } return nil } // FocusedTableReference returns the currently selected entry's information // from the focused table. func FocusedTableReference() (inv.SearchData, error) { var table *tview.Table err := fmt.Errorf("Application: Cannot select this entry") table = FocusedTable() if table == nil { return inv.SearchData{}, err } row, _ := table.GetSelection() for col := 0; col <= 1; col++ { cell := table.GetCell(row, col) if cell == nil { return inv.SearchData{}, err } info, ok := cell.GetReference().(inv.SearchData) if ok { return info, nil } } return inv.SearchData{}, err } // FocusedTable returns the currently focused table. func FocusedTable() *tview.Table { item := UI.GetFocus() if item, ok := item.(*tview.Table); ok { return item } return nil } invidtui-0.3.7/ui/menu/000077500000000000000000000000001454311651000147355ustar00rootroot00000000000000invidtui-0.3.7/ui/menu/functions.go000066400000000000000000000060721454311651000173010ustar00rootroot00000000000000package menu import ( "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/ui/player" "github.com/darkhz/invidtui/ui/view" ) func add(menuType string) bool { switch menuType { case "Channel", "Dashboard": return isVideo(menuType) case "Playlist": return playlistAddTo(menuType) } return isVideoOrChannel(menuType) } func remove(menuType string) bool { switch menuType { case "Playlist": return playlistRemoveFrom(menuType) } return isDashboardPlaylist(menuType) || isDashboardSubscription(menuType) } func query(menuType string) bool { switch menuType { case "History": return !player.IsHistoryInputFocused() case "Search": return !searchInputFocused(menuType) } return true } func searchInputFocused(menuType string) bool { return app.UI.Status.InputField.HasFocus() } func downloadView(menuType string) bool { d := view.Downloads return d != view.DownloadsView{} && !d.Primitive().HasFocus() } func downloadOptions(menuType string) bool { info, err := app.FocusedTableReference() return err == nil && info.Type == "video" } func isVideo(menuType string) bool { info, err := app.FocusedTableReference() return err == nil && info.Type == "video" } func isPlaylist(menuType string) bool { info, err := app.FocusedTableReference() return err == nil && info.Type == "playlist" } func isVideoOrChannel(menuType string) bool { info, err := app.FocusedTableReference() return err == nil && (info.Type == "video" && info.AuthorID != "" || info.Type == "channel") } func isVideoOrPlaylist(menuType string) bool { return isVideo(menuType) || isPlaylist(menuType) } func isDashboardFocused(menuType string) bool { focused := view.Dashboard.IsFocused() if focused { tabs := view.Dashboard.Tabs() return focused && tabs.Selected != "auth" } return false } func isDashboardPlaylist(menuType string) bool { return isDashboardFocused(menuType) && isPlaylist(menuType) } func createPlaylist(menuType string) bool { return isDashboardFocused(menuType) && view.Dashboard.CurrentPage() == "playlists" } func editPlaylist(menuType string) bool { return isDashboardFocused(menuType) && isPlaylist(menuType) } func isDashboardSubscription(menuType string) bool { return isDashboardFocused(menuType) && isVideoOrChannel(menuType) } func downloadViewVisible(menuType string) bool { d := view.Downloads return d != view.DownloadsView{} && d.Primitive().HasFocus() } func playerQueue(menuType string) bool { return !player.IsQueueEmpty() && !player.IsQueueFocused() } func queueMedia(menuType string) bool { if menuType == "Queue" { return player.IsQueueFocused() } return isVideo(menuType) } func infoShown(menuType string) bool { return isPlaying(menuType) && player.IsInfoShown() } func isPlaying(menuType string) bool { return player.IsPlayerShown() } func playlistAddTo(menuType string) bool { return isVideo(menuType) && !view.Dashboard.IsFocused() } func playlistRemoveFrom(menuType string) bool { prev := view.PreviousView() if prev == nil { return false } return isVideo(menuType) && prev.Name() == view.Dashboard.Name() } invidtui-0.3.7/ui/menu/items.go000066400000000000000000000077061454311651000164170ustar00rootroot00000000000000package menu import ( "github.com/darkhz/invidtui/cmd" "github.com/darkhz/invidtui/ui/app" ) // Items describes the menu items. var Items = &app.MenuData{ Items: map[cmd.KeyContext][]cmd.Key{ cmd.KeyContextApp: { cmd.KeyDashboard, cmd.KeyCancel, cmd.KeySuspend, cmd.KeyDownloadView, cmd.KeyDownloadOptions, cmd.KeyInstancesList, cmd.KeyQuit, }, cmd.KeyContextStart: { cmd.KeyQuery, }, cmd.KeyContextFiles: { cmd.KeyFilebrowserDirForward, cmd.KeyFilebrowserDirBack, cmd.KeyFilebrowserToggleHidden, cmd.KeyFilebrowserNewFolder, cmd.KeyFilebrowserRename, cmd.KeyClose, }, cmd.KeyContextPlaylist: { cmd.KeyComments, cmd.KeyLink, cmd.KeyAdd, cmd.KeyRemove, cmd.KeyLoadMore, cmd.KeyPlaylistSave, cmd.KeyDownloadOptions, cmd.KeyClose, }, cmd.KeyContextComments: { cmd.KeyCommentReplies, cmd.KeyClose, }, cmd.KeyContextDownloads: { cmd.KeyDownloadOptionSelect, cmd.KeyDownloadChangeDir, cmd.KeyDownloadCancel, cmd.KeyClose, }, cmd.KeyContextSearch: { cmd.KeySearchStart, cmd.KeyQuery, cmd.KeyLoadMore, cmd.KeySearchSwitchMode, cmd.KeySearchSuggestions, cmd.KeySearchParameters, cmd.KeyComments, cmd.KeyLink, cmd.KeyPlaylist, cmd.KeyChannelVideos, cmd.KeyChannelPlaylists, cmd.KeyAdd, cmd.KeyDownloadOptions, }, cmd.KeyContextChannel: { cmd.KeySwitchTab, cmd.KeyLoadMore, cmd.KeyQuery, cmd.KeyPlaylist, cmd.KeyAdd, cmd.KeyComments, cmd.KeyLink, cmd.KeyDownloadOptions, cmd.KeyClose, }, cmd.KeyContextDashboard: { cmd.KeySwitchTab, cmd.KeyDashboardReload, cmd.KeyLoadMore, cmd.KeyAdd, cmd.KeyComments, cmd.KeyPlaylist, cmd.KeyDashboardCreatePlaylist, cmd.KeyDashboardEditPlaylist, cmd.KeyChannelVideos, cmd.KeyChannelPlaylists, cmd.KeyRemove, cmd.KeyClose, }, cmd.KeyContextPlayer: { cmd.KeyPlayerOpenPlaylist, cmd.KeyQueue, cmd.KeyFetcher, cmd.KeyPlayerHistory, cmd.KeyPlayerInfo, cmd.KeyPlayerInfoChangeQuality, cmd.KeyPlayerQueueAudio, cmd.KeyPlayerQueueVideo, cmd.KeyPlayerPlayAudio, cmd.KeyPlayerPlayVideo, cmd.KeyAudioURL, cmd.KeyVideoURL, }, cmd.KeyContextQueue: { cmd.KeyQueuePlayMove, cmd.KeyQueueSave, cmd.KeyQueueAppend, cmd.KeyPlayerQueueAudio, cmd.KeyPlayerQueueVideo, cmd.KeyQueueDelete, cmd.KeyQueueMove, cmd.KeyQueueCancel, cmd.KeyClose, }, cmd.KeyContextFetcher: { cmd.KeyFetcherReload, cmd.KeyFetcherCancel, cmd.KeyFetcherReloadAll, cmd.KeyFetcherCancelAll, cmd.KeyFetcherClearCompleted, }, cmd.KeyContextHistory: { cmd.KeyQuery, cmd.KeyChannelVideos, cmd.KeyChannelPlaylists, cmd.KeyClose, }, }, Visible: map[cmd.Key]func(menuType string) bool{ cmd.KeyDownloadChangeDir: downloadView, cmd.KeyDownloadView: downloadView, cmd.KeyDownloadOptions: downloadOptions, cmd.KeyComments: isVideo, cmd.KeyLink: isVideo, cmd.KeyDownloadCancel: downloadViewVisible, cmd.KeyAdd: add, cmd.KeyRemove: remove, cmd.KeyPlaylist: isPlaylist, cmd.KeyChannelVideos: isVideoOrChannel, cmd.KeyChannelPlaylists: isVideoOrChannel, cmd.KeyQuery: query, cmd.KeySearchStart: searchInputFocused, cmd.KeySearchSwitchMode: searchInputFocused, cmd.KeySearchSuggestions: searchInputFocused, cmd.KeySearchParameters: searchInputFocused, cmd.KeyDashboardReload: isDashboardFocused, cmd.KeyDashboardCreatePlaylist: createPlaylist, cmd.KeyDashboardEditPlaylist: editPlaylist, cmd.KeyQueue: playerQueue, cmd.KeyPlayerInfo: isPlaying, cmd.KeyPlayerInfoChangeQuality: infoShown, cmd.KeyPlayerQueueAudio: queueMedia, cmd.KeyPlayerQueueVideo: queueMedia, cmd.KeyPlayerPlayAudio: isVideo, cmd.KeyPlayerPlayVideo: isVideo, }, } invidtui-0.3.7/ui/player/000077500000000000000000000000001454311651000152655ustar00rootroot00000000000000invidtui-0.3.7/ui/player/fetcher.go000066400000000000000000000235641454311651000172460ustar00rootroot00000000000000package player import ( "context" "errors" "fmt" "strings" "sync" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/tview" "github.com/gammazero/deque" "github.com/gdamore/tcell/v2" "golang.org/x/sync/semaphore" ) // Fetcher describes the media fetcher. type Fetcher struct { modal *app.Modal table *tview.Table info *tview.TextView marker *tview.TableCell items *deque.Deque[*FetcherData] mutex sync.Mutex lock *semaphore.Weighted tview.TableContentReadOnly } // FetcherData describes the media fetcher data. type FetcherData struct { Columns [FetchColumnSize]*tview.TableCell Info inv.SearchData Error error Audio bool ctx context.Context cancel context.CancelFunc } // FetcherStatus describes the status of each media fetcher entry. type FetcherStatus string const ( FetcherStatusAdding FetcherStatus = "Adding" FetcherStatusError FetcherStatus = "Error" ) const ( FetchColumnSize = 7 FetchStatusMarker = FetchColumnSize - 1 ) // Setup sets up the media fetcher. func (f *Fetcher) Setup() { f.items = deque.New[*FetcherData](100) f.info = tview.NewTextView() f.info.SetDynamicColors(true) f.info.SetBackgroundColor(tcell.ColorDefault) f.table = tview.NewTable() f.table.SetContent(f) f.table.SetSelectorWrap(true) f.table.SetSelectable(true, false) f.table.SetInputCapture(f.Keybindings) f.table.SetBackgroundColor(tcell.ColorDefault) f.table.SetSelectionChangedFunc(f.selectorHandler) f.table.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextFetcher, f.table) }) flex := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(f.table, 0, 10, true). AddItem(app.HorizontalLine(), 1, 0, false). AddItem(f.info, 0, 1, false) f.modal = app.NewModal("fetcher", "Media Fetcher", flex, 100, 100) f.lock = semaphore.NewWeighted(10) } // Show shows the media fetcher. func (f *Fetcher) Show() { if f.IsOpen() { return } if f.Count() == 0 { app.ShowInfo("Media Fetcher: No items are being added", false) return } f.modal.Show(false) } // Hide hides the media fetcher. func (f *Fetcher) Hide() { f.modal.Exit(false) } // IsOpen returns whether the media fetcher is open. func (f *Fetcher) IsOpen() bool { return f.modal != nil && f.modal.Open } // Fetch loads media and adds it to the media fetcher. func (f *Fetcher) Fetch(info inv.SearchData, audio bool, newdata ...*FetcherData) (inv.SearchData, error) { data, ctx := f.Add(info, audio, newdata...) f.MarkStatus(data, FetcherStatusAdding, nil) defer f.UpdateTag(false) err := f.lock.Acquire(ctx, 1) if err != nil { return inv.SearchData{}, err } defer f.lock.Release(1) switch info.Type { case "playlist": var videos []inv.VideoData videos, err = inv.PlaylistVideos(ctx, info.PlaylistID, false, func(stats [3]int64) { f.MarkStatus(data, FetcherStatusAdding, nil, fmt.Sprintf("(%d of %d)", stats[1], stats[2])) }) if err == nil { for _, v := range videos { player.queue.Add(v, audio) } } case "video": var video inv.VideoData video, err = inv.Video(info.VideoID, ctx) if err == nil { player.queue.Add(video, audio) info.Title = video.Title } } if err != nil { if !errors.Is(err, context.Canceled) { f.MarkStatus(data, FetcherStatusError, err) } return inv.SearchData{}, err } f.Remove(data) return info, nil } // Add sets/adds entry data in the media fetcher. func (f *Fetcher) Add( info inv.SearchData, audio bool, newdata ...*FetcherData, ) (*FetcherData, context.Context) { defer f.UpdateTag(false) f.mutex.Lock() defer f.mutex.Unlock() ctx, cancel := context.WithCancel(context.Background()) media := "Audio" if !audio { media = "Video" } data := &FetcherData{ Columns: [FetchColumnSize]*tview.TableCell{ tview.NewTableCell("[blue::b]" + tview.Escape(info.Title)). SetExpansion(1). SetMaxWidth(15). SetSelectable(true). SetSelectedStyle(app.UI.SelectedStyle), tview.NewTableCell(" "). SetMaxWidth(1). SetSelectable(false), tview.NewTableCell("[purple::b]" + tview.Escape(info.Author)). SetExpansion(1). SetMaxWidth(15). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), tview.NewTableCell(" "). SetMaxWidth(1). SetSelectable(false), tview.NewTableCell(fmt.Sprintf("[pink::b]%s (%s)", info.Type, media)). SetSelectable(false). SetAlign(tview.AlignRight), tview.NewTableCell(" "). SetMaxWidth(1). SetSelectable(false), tview.NewTableCell(string(FetcherStatusAdding)). SetSelectable(false), }, Info: info, Audio: audio, ctx: ctx, cancel: cancel, } data.Columns[0].SetReference(data) if newdata != nil { pos := f.items.Index(func(d *FetcherData) bool { return d == newdata[0] }) if pos >= 0 { newdata[0].cancel() f.items.Set(pos, data) return data, ctx } } f.items.PushFront(data) return data, ctx } // Remove removes entry data from the media fetcher. func (f *Fetcher) Remove(data *FetcherData) { f.mutex.Lock() defer f.mutex.Unlock() pos := f.items.Index(func(d *FetcherData) bool { return d == data }) if pos >= 0 { f.items.Remove(pos) } if f.items.Len() == 0 { f.Hide() f.UpdateTag(true) } } // MarkStatus marks the status of the media fetcher entry. func (f *Fetcher) MarkStatus(data *FetcherData, status FetcherStatus, err error, text ...string) { f.mutex.Lock() defer f.mutex.Unlock() data.Error = err color := "yellow" if status == FetcherStatusError { color = "red" } cell := data.Columns[FetchStatusMarker] if cell != nil { extra := "" if text != nil { extra = text[0] } go app.UI.QueueUpdateDraw(func() { cell.SetText(fmt.Sprintf(`[%s::b][%s[][-:-:-] %s`, color, status, extra)) pos, _ := f.table.GetSelection() f.table.Select(pos, 0) }) } } // UpdateTag updates the status bar tag according to the media fetcher status. func (f *Fetcher) UpdateTag(clear bool) { var tag, wrap string var info []string var queuedCount, errorCount int if clear { goto Tag } f.mutex.Lock() f.items.Index(func(d *FetcherData) bool { if d.Error != nil { errorCount++ return false } queuedCount++ return false }) f.mutex.Unlock() if queuedCount > 0 { info = append(info, fmt.Sprintf("Queuing %d", queuedCount)) } if errorCount > 0 { info = append(info, fmt.Sprintf("Errors %d", errorCount)) } if info == nil { goto Tag } wrap = fmt.Sprintf(" (%s)", strings.Join(info, ", ")) tag = fmt.Sprintf("[black:yellow:b]Media Fetcher%s[-:-:-]", wrap) Tag: go app.UI.Status.Tag(tag) } // Count returns the number of items in the media fetcher. func (f *Fetcher) Count() int { f.mutex.Lock() defer f.mutex.Unlock() return f.items.Len() } // FetchAll fetches all the items in the media fetcher. func (f *Fetcher) FetchAll() { f.mutex.Lock() defer f.mutex.Unlock() f.items.Index(func(d *FetcherData) bool { go f.Fetch(d.Info, d.Audio, d) return false }) } // Cancel cancels fetching an item in the media fetcher. func (f *Fetcher) Cancel(data *FetcherData) { f.mutex.Lock() data.cancel() f.mutex.Unlock() f.Remove(data) } // Cancel cancels fetching all the items in the media fetcher. func (f *Fetcher) CancelAll(clear bool) { f.mutex.Lock() f.items.Index(func(d *FetcherData) bool { d.cancel() return false }) f.mutex.Unlock() if clear { f.Clear() f.UpdateTag(clear) } } // Clear clears the media fetcher. func (f *Fetcher) Clear() { f.mutex.Lock() defer f.mutex.Unlock() f.items.Clear() } // ClearErrors clears all the errors in the media fetcher. func (f *Fetcher) ClearErrors() { f.mutex.Lock() for { pos := f.items.Index(func(d *FetcherData) bool { return d.Error != nil }) if pos < 0 { break } f.items.At(pos).cancel() f.items.Remove(pos) } f.mutex.Unlock() if f.Count() == 0 { f.Hide() f.UpdateTag(true) } } // Get returns the entry data at the specified position from the media fetcher. func (f *Fetcher) Get(position int) (FetcherData, bool) { f.mutex.Lock() defer f.mutex.Unlock() length := f.items.Len() if position < 0 || position >= length { return FetcherData{}, false } return *f.items.At(position), true } // GetCell returns a TableCell from the media fetcher entry data at the specified row and column. func (f *Fetcher) GetCell(row, column int) *tview.TableCell { data, ok := f.Get(row) if !ok { return nil } return data.Columns[column] } // GetRowCount returns the number of rows in the table. func (f *Fetcher) GetRowCount() int { return f.Count() } // GetColumnCount returns the number of columns in the table. func (f *Fetcher) GetColumnCount() int { return FetchColumnSize } // GetReference returns the reference of the currently selected column in the table. func (f *Fetcher) GetReference(do ...func(d *FetcherData)) (*FetcherData, bool) { row, _ := f.table.GetSelection() ref := f.table.GetCell(row, 0).Reference data, ok := ref.(*FetcherData) if ok && do != nil { do[0](data) } return data, ok } // Keybindings define the keybindings for the media fetcher. func (f *Fetcher) Keybindings(event *tcell.EventKey) *tcell.EventKey { operation := cmd.KeyOperation(event, cmd.KeyContextFetcher) switch operation { case cmd.KeyFetcherClearCompleted: f.ClearErrors() case cmd.KeyFetcherCancel: f.GetReference(func(d *FetcherData) { f.Cancel(d) }) case cmd.KeyFetcherReload: f.GetReference(func(d *FetcherData) { go f.Fetch(d.Info, d.Audio, d) }) case cmd.KeyFetcherCancelAll: f.CancelAll(true) case cmd.KeyFetcherReloadAll: f.FetchAll() case cmd.KeyPlayerStop, cmd.KeyClose: f.Hide() } return event } // selectorHandler shows any error messages for any selcted fetcher entry. func (f *Fetcher) selectorHandler(row, col int) { f.info.Clear() data, ok := f.Get(row) if !ok { return } info := "No errors" if data.Error != nil { info = fmt.Sprintf("[red::bu]Error:[-:-:-]\n[::b]%s[-:-:-]", data.Error.Error()) } f.info.SetText(info) } invidtui-0.3.7/ui/player/history.go000066400000000000000000000117301454311651000173170ustar00rootroot00000000000000package player import ( "strings" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/ui/view" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // History describes the layout of the history popup // and stores the entries. type History struct { entries []cmd.PlayHistorySettings modal *app.Modal flex *tview.Flex table *tview.Table input *tview.InputField } // loadHistory loads the saved play history. func loadHistory() { player.history.entries = cmd.Settings.PlayHistory } // addToHistory adds a currently playing item to the history. func addToHistory(data inv.SearchData) { player.mutex.Lock() defer player.mutex.Unlock() info := cmd.PlayHistorySettings{ Type: data.Type, Title: data.Title, Author: data.Author, VideoID: data.VideoID, PlaylistID: data.PlaylistID, AuthorID: data.AuthorID, } if len(player.history.entries) != 0 && player.history.entries[0] == info { return } prevInfo := info for i, phInfo := range player.history.entries { switch { case i == 0: player.history.entries[0] = info prevInfo = phInfo case phInfo == info: player.history.entries[i] = prevInfo return default: player.history.entries[i] = prevInfo prevInfo = phInfo } } player.history.entries = append(player.history.entries, prevInfo) cmd.Settings.PlayHistory = player.history.entries } // showHistory shows a popup with the history entries. func showHistory() { var history []cmd.PlayHistorySettings player.mutex.Lock() history = player.history.entries player.mutex.Unlock() if len(history) == 0 { return } if player.history.modal != nil { if player.history.modal.Open { return } goto Render } player.history.table = tview.NewTable() player.history.table.SetSelectorWrap(true) player.history.table.SetSelectable(true, false) player.history.table.SetBackgroundColor(tcell.ColorDefault) player.history.table.SetInputCapture(historyTableKeybindings) player.history.table.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextHistory, player.history.table) }) player.history.input = tview.NewInputField() player.history.input.SetLabel("[::b]Filter: ") player.history.input.SetChangedFunc(historyFilter) player.history.input.SetLabelColor(tcell.ColorWhite) player.history.input.SetBackgroundColor(tcell.ColorDefault) player.history.input.SetFieldBackgroundColor(tcell.ColorDefault) player.history.input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEscape, tcell.KeyEnter: app.UI.SetFocus(player.history.table) } return event }) player.history.flex = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(player.history.table, 0, 10, true). AddItem(app.HorizontalLine(), 1, 0, false). AddItem(player.history.input, 1, 0, false) player.history.modal = app.NewModal("player_history", "Previously played", player.history.flex, 40, 0) Render: player.history.modal.Show(true) historyFilter("") } // historyTableKeybindings defines the keybindings for the history popup. func historyTableKeybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event) { case cmd.KeyQuery: app.UI.SetFocus(player.history.input) case cmd.KeyChannelVideos: view.Channel.EventHandler("video", event.Modifiers() == tcell.ModAlt) case cmd.KeyChannelPlaylists: view.Channel.EventHandler("playlist", event.Modifiers() == tcell.ModAlt) case cmd.KeyClose: player.history.modal.Exit(false) } for _, k := range []cmd.Key{cmd.KeyChannelVideos, cmd.KeyChannelPlaylists} { if cmd.KeyOperation(event) == k { player.history.modal.Exit(false) app.UI.Status.SwitchToPage("messages") break } } return event } // historyFilter filters the history entries according to the provided text. // This handler is attached to the history popup's input. func historyFilter(text string) { var row int text = strings.ToLower(text) player.history.table.Clear() for _, ph := range player.history.entries { if text != "" && !strings.Contains(strings.ToLower(ph.Title), text) { continue } info := inv.SearchData{ Type: ph.Type, Title: ph.Title, Author: ph.Author, VideoID: ph.VideoID, PlaylistID: ph.PlaylistID, AuthorID: ph.AuthorID, } player.history.table.SetCell(row, 0, tview.NewTableCell("[blue::b]"+ph.Title). SetExpansion(1). SetReference(info). SetSelectedStyle(app.UI.SelectedStyle), ) player.history.table.SetCell(row, 1, tview.NewTableCell(""). SetSelectable(false), ) player.history.table.SetCell(row, 2, tview.NewTableCell("[purple::b]"+ph.Author). SetSelectedStyle(app.UI.ColumnStyle), ) player.history.table.SetCell(row, 3, tview.NewTableCell(""). SetSelectable(false), ) player.history.table.SetCell(row, 4, tview.NewTableCell("[pink]"+ph.Type). SetSelectedStyle(app.UI.ColumnStyle), ) row++ } player.history.table.ScrollToBeginning() app.ResizeModal() } invidtui-0.3.7/ui/player/player.go000066400000000000000000000472641454311651000171250ustar00rootroot00000000000000package player import ( "context" "fmt" "image/jpeg" "path/filepath" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" mp "github.com/darkhz/invidtui/mediaplayer" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" "golang.org/x/sync/semaphore" ) // Player stores the layout for the player. type Player struct { queue Queue fetcher Fetcher infoID, thumbURI string init bool width int history History states []string channel chan bool events chan struct{} image *tview.Image flex, region *tview.Flex info *tview.TextView quality *tview.DropDown title, desc *tview.TextView ctx context.Context cancel, infoCancel, imgCancel context.CancelFunc status, setting, toggle atomic.Bool lock *semaphore.Weighted mutex sync.Mutex } var player Player // setup sets up the player. func setup() { if player.init { return } player.init = true player.channel = make(chan bool, 10) player.events = make(chan struct{}, 100) player.title, player.desc = tview.NewTextView(), tview.NewTextView() player.desc.SetDynamicColors(true) player.title.SetDynamicColors(true) player.desc.SetTextAlign(tview.AlignCenter) player.title.SetTextAlign(tview.AlignCenter) player.desc.SetBackgroundColor(tcell.ColorDefault) player.title.SetBackgroundColor(tcell.ColorDefault) player.image = tview.NewImage() player.image.SetBackgroundColor(tcell.ColorDefault) player.image.SetDithering(tview.DitheringFloydSteinberg) player.info = tview.NewTextView() player.info.SetDynamicColors(true) player.info.SetTextAlign(tview.AlignCenter) player.info.SetBackgroundColor(tcell.ColorDefault) player.quality = tview.NewDropDown() player.quality.SetLabel("[green::b]Quality: ") player.quality.SetBackgroundColor(tcell.ColorDefault) player.quality.SetFieldTextColor(tcell.ColorOrangeRed) player.quality.SetFieldBackgroundColor(tcell.ColorDefault) player.quality.List(). SetMainTextColor(tcell.ColorWhite). SetBackgroundColor(tcell.ColorDefault). SetBorder(true) player.flex = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(player.title, 1, 0, false). AddItem(player.desc, 1, 0, false) player.flex.SetBackgroundColor(tcell.ColorDefault) player.region = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(player.image, 0, 1, false). AddItem(player.info, 0, 1, false) player.region.SetBackgroundColor(tcell.ColorDefault) player.lock = semaphore.NewWeighted(10) } // Start starts the player and loads its history and states. func Start() { setup() player.queue.Setup() player.fetcher.Setup() loadState() loadHistory() mp.SetEventHandler(mediaEventHandler) go playingStatusCheck() } // Stop stops the player. func Stop() { sendPlayingStatus(false) player.mutex.Lock() cmd.Settings.PlayerStates = player.states player.mutex.Unlock() mp.Player().Stop() mp.Player().Exit() } // Show shows the player. func Show() { if player.status.Load() || !player.setting.Load() { return } player.status.Store(true) sendPlayingStatus(true) app.UI.QueueUpdateDraw(func() { app.UI.Layout.AddItem(player.flex, 2, 0, false) app.ResizeModal() }) } // ToggleInfo toggle the player information view. func ToggleInfo(hide ...struct{}) { if hide != nil || player.toggle.Load() { player.toggle.Store(false) player.infoID = "" infoContext(true, struct{}{}) app.UI.Region.Clear(). AddItem(app.UI.Pages, 0, 1, true) if player.region.GetItemCount() > 2 { player.region.RemoveItemIndex(1) } return } if !player.toggle.Load() && player.status.Load() { player.toggle.Store(true) box := tview.NewBox() box.SetBackgroundColor(tcell.ColorDefault) app.UI.Region.Clear(). AddItem(player.region, 25, 0, false). AddItem(box, 1, 0, false). AddItem(app.VerticalLine(), 1, 0, false). AddItem(box, 1, 0, false). AddItem(app.UI.Pages, 0, 1, true) Resize(0, struct{}{}) if data, ok := player.queue.GetCurrent(); ok { go renderInfo(data.Reference) } } } // Hide hides the player. func Hide() { if player.setting.Load() { return } Context(true) player.queue.Context(true) player.status.Store(false) sendPlayingStatus(false) app.UI.QueueUpdateDraw(func() { ToggleInfo(struct{}{}) app.ResizeModal() app.UI.Layout.RemoveItem(player.flex) }) mp.Player().Stop() player.queue.Clear() player.fetcher.CancelAll(true) } // Context cancels and/or returns the player's context. func Context(cancel bool) context.Context { if cancel && player.ctx != nil { player.cancel() } if player.ctx == nil || player.ctx.Err() == context.Canceled { player.ctx, player.cancel = context.WithCancel(context.Background()) } return player.ctx } // Resize resizes the player according to the screen width. func Resize(width int, force ...struct{}) { if force != nil { _, _, w, _ := app.UI.Area.GetRect() width = w goto ResizePlayer } if width == player.width { return } ResizePlayer: sendPlayerEvents() app.UI.Region.ResizeItem(player.region, (width / 4), 0) player.width = width } // ParseQuery parses the play-audio or play-video commandline // parameters, and plays the provided URL. func ParseQuery() { setup() mtype, uri, err := cmd.GetQueryParams("play") if err != nil { return } playFromURL(uri, mtype == "audio") } // Play plays the currently selected audio/video entry. func Play(audio, current bool, mediaInfo ...inv.SearchData) { var err error var media string var info inv.SearchData if mediaInfo != nil { info = mediaInfo[0] } else { info, err = app.FocusedTableReference() if err != nil { return } } if audio { media = "audio" } else { media = "video" } if info.Type == "channel" { app.ShowError(fmt.Errorf("Player: Cannot play %s for channel type", media)) return } player.setting.Store(true) go loadSelected(info, audio, current) } // IsInfoShown returns whether the player information is shown. func IsInfoShown() bool { return player.region != nil && player.toggle.Load() } // IsPlayerShown returns whether the player is shown. func IsPlayerShown() bool { return player.status.Load() } // IsQueueFocused returns whether the queue is focused. func IsQueueFocused() bool { return player.queue.table != nil && player.queue.table.HasFocus() } // IsQueueEmpty returns whether the queue is empty. func IsQueueEmpty() bool { return player.queue.table == nil || player.queue.Count() == 0 } // IsHistoryInputFocused returns whether the history search bar is focused. func IsHistoryInputFocused() bool { return player.history.input != nil && player.history.input.HasFocus() } // Keybindings define the main player keybindings. func Keybindings(event *tcell.EventKey) *tcell.EventKey { playerKeybindings(event) operation := cmd.KeyOperation(event, cmd.KeyContextQueue, cmd.KeyContextFetcher) switch operation { case cmd.KeyPlayerOpenPlaylist: app.UI.FileBrowser.Show("Open playlist:", openPlaylist) case cmd.KeyPlayerHistory: showHistory() case cmd.KeyPlayerInfo: ToggleInfo() case cmd.KeyPlayerInfoScrollDown: player.info.InputHandler()(tcell.NewEventKey(tcell.KeyDown, ' ', tcell.ModNone), nil) return nil case cmd.KeyPlayerInfoScrollUp: player.info.InputHandler()(tcell.NewEventKey(tcell.KeyUp, ' ', tcell.ModNone), nil) return nil case cmd.KeyPlayerInfoChangeQuality: changeImageQuality() case cmd.KeyPlayerQueueAudio, cmd.KeyPlayerQueueVideo, cmd.KeyPlayerPlayAudio, cmd.KeyPlayerPlayVideo: playSelected(operation) case cmd.KeyFetcher: player.fetcher.Show() case cmd.KeyQueue: player.queue.Show() case cmd.KeyQueueCancel: player.queue.Context(true) case cmd.KeyAudioURL, cmd.KeyVideoURL: playInputURL(event.Rune() == 'b') return nil } return event } // playerKeybindings define the playback-related keybindings // for the player. func playerKeybindings(event *tcell.EventKey) { var nokey bool switch cmd.KeyOperation(event, cmd.KeyContextPlayer) { case cmd.KeyPlayerStop: player.setting.Store(false) go Hide() case cmd.KeyPlayerSeekForward: mp.Player().SeekForward() case cmd.KeyPlayerSeekBackward: mp.Player().SeekBackward() case cmd.KeyPlayerTogglePlay: mp.Player().TogglePaused() case cmd.KeyPlayerToggleLoop: player.queue.ToggleRepeatMode() case cmd.KeyPlayerToggleShuffle: player.queue.ToggleShuffle() case cmd.KeyPlayerToggleMute: mp.Player().ToggleMuted() case cmd.KeyPlayerVolumeIncrease: mp.Player().VolumeIncrease() case cmd.KeyPlayerVolumeDecrease: mp.Player().VolumeDecrease() case cmd.KeyPlayerPrev: player.queue.Previous(struct{}{}) case cmd.KeyPlayerNext: player.queue.Next(struct{}{}) default: nokey = true } if !nokey { sendPlayerEvents() } } // playSelected determines the media type according // to the key pressed, and plays the currently selected entry. func playSelected(key cmd.Key) { if player.queue.IsOpen() { kb := cmd.OperationData(key) player.queue.Keybindings(tcell.NewEventKey(kb.Kb.Key, kb.Kb.Rune, kb.Kb.Mod)) return } audio := key == cmd.KeyPlayerQueueAudio || key == cmd.KeyPlayerPlayAudio current := key == cmd.KeyPlayerPlayAudio || key == cmd.KeyPlayerPlayVideo Play(audio, current) table := app.FocusedTable() if table != nil { table.InputHandler()( tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), nil, ) } } // playInputURL displays an inputbox and plays the entered URL. func playInputURL(audio bool) { media := "video" if audio { media = "audio" } dofunc := func(text string) { playFromURL(text, audio) } app.UI.Status.SetInput("Play "+media+" for video/playlist URL or ID:", 0, true, dofunc, nil) } // playFromURL plays the given URL. func playFromURL(text string, audio bool) { id, mtype, err := utils.GetVPIDFromURL(text) if err != nil { app.ShowError(err) return } info := inv.SearchData{ Title: text, Type: mtype, } if mtype == "video" { info.VideoID = id } else { info.PlaylistID = id } Play(audio, false, info) } // loadSelected loads the provided entry according to its type (video/playlist). func loadSelected(info inv.SearchData, audio, current bool) { var err error if info.Type != "video" && info.Type != "playlist" { return } info, err = player.fetcher.Fetch(info, audio) if err != nil { return } addToHistory(info) if current && info.Type == "video" { player.queue.SelectRecentEntry() } } // renderPlayer renders the media player within the app. func renderPlayer() { var width int app.UI.QueueUpdateDraw(func() { _, _, width, _ = player.desc.GetRect() }) progress, states := updateProgressAndInfo(width - 10) app.UI.QueueUpdateDraw(func() { player.title.SetText("[::b]" + tview.Escape(player.queue.GetTitle())) player.desc.SetText(progress + " " + player.queue.marker.Text) }) player.mutex.Lock() player.states = states player.mutex.Unlock() } // changeImageQuality sets or displays options to change the quality of the image // in the player information area. // //gocyclo:ignore func changeImageQuality(set ...struct{}) { var prev string var options []string data, ok := player.queue.GetCurrent() if !ok { return } video := data.Reference start, pos := -1, -1 for i, thumb := range video.Thumbnails { if thumb.Quality == "start" { start = i break } if thumb.URL == player.thumbURI { pos = i } if set == nil { text := fmt.Sprintf("%dx%d", thumb.Width, thumb.Height) if prev == text { continue } prev = text options = append(options, text) } } if start >= 0 && (pos < 0 || player.thumbURI == "") { pos = len(options) - 1 player.thumbURI = video.Thumbnails[start-1].URL } if set != nil || !player.toggle.Load() || player.quality.HasFocus() { return } thumb := video.Thumbnails[pos] for i, option := range options { if option == fmt.Sprintf("%dx%d", thumb.Width, thumb.Height) { pos = i break } } player.region.Clear(). AddItem(player.image, 0, 1, false). AddItem(player.quality, 1, 0, false). AddItem(player.info, 0, 1, false) player.quality.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEscape: app.SetPrimaryFocus() player.region.RemoveItem(player.quality) } return event }) player.quality.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { _, _, w, _ := player.quality.List().GetRect() dx := ((width / 2) - w) + 2 if dx < 0 { dx = 0 } return dx, y, width, height }) player.quality.SetOptions(options, func(text string, index int) { if index < 0 { return } for i, thumb := range video.Thumbnails { if text == fmt.Sprintf("%dx%d", thumb.Width, thumb.Height) { index = i break } } if uri := video.Thumbnails[index].URL; uri != player.thumbURI { player.thumbURI = uri go renderInfoImage(infoContext(true), player.infoID, filepath.Base(uri), struct{}{}) } }) player.quality.SetCurrentOption(pos) player.quality.InputHandler()( tcell.NewEventKey(tcell.KeyEnter, ' ', tcell.ModNone), func(p tview.Primitive) { app.UI.SetFocus(p) }, ) app.UI.SetFocus(player.quality) go app.UI.Draw() } // renderInfo renders the track information. func renderInfo(video inv.VideoData, force ...struct{}) { if force == nil && (video.VideoID == player.infoID || !player.toggle.Load()) { return } infoContext(true, struct{}{}) app.UI.QueueUpdateDraw(func() { player.info.SetText("[::b]Loading information...") player.image.SetImage(nil) }) if video.Thumbnails == nil { go func(ctx context.Context, pos int, v inv.VideoData) { v, err := inv.Video(v.VideoID, ctx) if err != nil { if ctx.Err() != context.Canceled { app.UI.QueueUpdateDraw(func() { player.info.SetText("[::b]No information for\n" + v.Title) }) } return } player.queue.SetReference(pos, v, struct{}{}) renderInfo(v, struct{}{}) }(infoContext(false), player.queue.Position(), video) return } app.UI.QueueUpdateDraw(func() { player.infoID = video.VideoID if player.region.GetItemCount() > 2 { player.region.RemoveItemIndex(1) } text := "\n" if video.Author != "" { text += fmt.Sprintf("[::bu]%s[-:-:-]\n\n", video.Author) } if video.PublishedText != "" { text += fmt.Sprintf("[lightpink::b]Uploaded %s[-:-:-]\n", video.PublishedText) } text += fmt.Sprintf( "[aqua::b]%s views[-:-:-] / [red::b]%s likes[-:-:-] / [purple::b]%s subscribers[-:-:-]\n\n", utils.FormatNumber(video.ViewCount), utils.FormatNumber(video.LikeCount), video.SubCountText, ) text += "[::b]" + tview.Escape(video.Description) player.info.SetText(text) player.info.ScrollToBeginning() changeImageQuality(struct{}{}) }) go renderInfoImage(infoContext(true), video.VideoID, filepath.Base(player.thumbURI)) } // renderInfoImage renders the image for the track information display. func renderInfoImage(ctx context.Context, id, image string, change ...struct{}) { if image == "." { return } app.ShowInfo("Player: Loading image", true, change != nil) thumbdata, err := inv.VideoThumbnail(ctx, id, image) if err != nil { if ctx.Err() != context.Canceled { app.ShowError(fmt.Errorf("Player: Unable to download thumbnail")) } app.ShowInfo("", false, change != nil) return } thumbnail, err := jpeg.Decode(thumbdata.Body) if err != nil { app.ShowError(fmt.Errorf("Player: Unable to decode thumbnail")) return } app.UI.QueueUpdateDraw(func() { player.image.SetImage(thumbnail) }) app.ShowInfo("Player: Image loaded", false, change != nil) } // playingStatusCheck monitors the playing status. func playingStatusCheck() { for { playing, ok := <-player.channel if !ok { return } if !playing { continue } Context(false) go playerUpdateLoop(player.ctx, player.cancel) } } // playerUpdateLoop updates the player. func playerUpdateLoop(ctx context.Context, cancel context.CancelFunc) { t := time.NewTicker(1 * time.Second) defer t.Stop() for { select { case <-ctx.Done(): player.desc.Clear() player.title.Clear() return case <-player.events: renderPlayer() continue case <-t.C: renderPlayer() } } } // mediaEventHandler monitors events sent from MPV. func mediaEventHandler(event mp.MediaEvent) { switch event { case mp.EventInProgress: player.queue.MarkPlayingEntry(EntryPlaying) case mp.EventEnd: player.queue.MarkPlayingEntry(EntryStopped) player.queue.AutoPlay(false) case mp.EventError: if data, ok := player.queue.GetCurrent(); !ok { app.ShowError(fmt.Errorf("Player: Unable to play %q", data.Reference.Title)) } player.queue.AutoPlay(true) } } // openPlaylist loads the provided playlist file. func openPlaylist(file string) { app.ShowInfo("Loading "+filepath.Base(file), true) player.setting.Store(true) err := player.queue.LoadPlaylist(player.queue.Context(false), file, true) if err != nil { app.ShowError(err) return } app.UI.QueueUpdateDraw(func() { player.queue.Show() app.UI.FileBrowser.Hide() }) app.ShowInfo("Loaded "+filepath.Base(file), false) } // updateProgressAndInfo returns the progress bar and information // of the currently playing track, and updates the track information. // //gocyclo:ignore func updateProgressAndInfo(width int) (string, []string) { var lhs, rhs string var states []string eof := mp.Player().Finished() paused := mp.Player().Paused() buffering := mp.Player().Buffering() shuffle := player.queue.GetShuffleMode() mute := mp.Player().Muted() volume := mp.Player().Volume() duration := mp.Player().Duration() timepos := mp.Player().Position() currtime := utils.FormatDuration(timepos) vol := strconv.Itoa(volume) if vol == "" { vol = "0" } states = append(states, "volume "+vol) vol += "%" if timepos < 0 { timepos = 0 } if duration < 0 { duration = 0 } if timepos > duration { timepos = duration } totaltime := utils.FormatDuration(duration) mtype := "(" + player.queue.GetMediaType() + ")" width /= 2 length := width * int(timepos) if duration > 0 { length /= int(duration) } endlength := width - length if endlength < 0 { endlength = width } if shuffle { lhs += " S" states = append(states, "shuffle") } if mute { lhs += " M" states = append(states, "mute") } loop, loopsetting := "", "" switch player.queue.GetRepeatMode() { case mp.RepeatModeOff: loop = "" case mp.RepeatModeFile: loop = "R-F" loopsetting = "loop-file" case mp.RepeatModePlaylist: loop = "R-P" loopsetting = "loop-playlist" } if loop != "" { states = append(states, loopsetting) } state := "" switch { case paused: if eof { state = "[]" } else { state = "||" } case buffering: state = "B" if pct := mp.Player().BufferPercentage(); pct >= 0 { state += "(" + strconv.Itoa(pct) + "%)" } default: state = ">" } rhs = " " + vol + " " + mtype lhs = loop + lhs + " " + state + " " progress := currtime + " |" + strings.Repeat("â–ˆ", length) + strings.Repeat(" ", endlength) + "| " + totaltime return (lhs + progress + rhs), states } // sendPlayingStatus sends status events to the player. // If playing is true, the player is shown and vice-versa. func sendPlayingStatus(playing bool) { select { case player.channel <- playing: return default: } } // sendPlayerEvents triggers updates for the player. func sendPlayerEvents() { select { case player.events <- struct{}{}: return default: } } // infoContext returns a new context for loading the player information. func infoContext(image bool, all ...struct{}) context.Context { ctx, cancel := context.WithCancel(context.Background()) if image { if player.imgCancel != nil { player.imgCancel() } player.imgCancel = cancel if all == nil { goto InfoContext } } if player.infoCancel != nil { player.infoCancel() } player.infoCancel = cancel InfoContext: return ctx } invidtui-0.3.7/ui/player/queue.go000066400000000000000000000517431454311651000167520ustar00rootroot00000000000000package player import ( "context" "errors" "fmt" "math/rand" "net/url" "os" "path/filepath" "strings" "sync" "sync/atomic" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" mp "github.com/darkhz/invidtui/mediaplayer" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/etherlabsio/go-m3u8/m3u8" "github.com/gammazero/deque" "github.com/gdamore/tcell/v2" "golang.org/x/sync/semaphore" ) // Queue describes the media queue. type Queue struct { init, moveMode bool prevrow int videos map[string]*inv.VideoData status chan struct{} modal *app.Modal table *tview.Table marker *tview.TableCell lock *semaphore.Weighted ctx, playctx context.Context cancel, playcancel context.CancelFunc position, repeat atomic.Int32 shuffle, audio atomic.Bool title atomic.Value store *deque.Deque[*QueueData] storeMutex sync.Mutex current *QueueData tview.TableContentReadOnly } // QueueData describes the queue entry data. type QueueData struct { URI []string Reference inv.VideoData Columns [QueueColumnSize]*tview.TableCell Audio, Playing, HasPlayed bool } // QueueEntryStatus describes the status of a queue entry. type QueueEntryStatus string const ( EntryFetching QueueEntryStatus = "Fetching" EntryLoading QueueEntryStatus = "Loading" EntryPlaying QueueEntryStatus = "Playing" EntryStopped QueueEntryStatus = "Stopped" ) const ( QueueColumnSize = 10 QueuePlayingMarker = QueueColumnSize - 2 QueueMediaMarker = QueueColumnSize - 5 PlayerMarkerFormat = `[%s::b][%s[][-:-:-]` MediaMarkerFormat = `[pink::b]%s[-:-:-]` ) // Setup sets up the queue. func (q *Queue) Setup() { if q.init { return } q.store = deque.New[*QueueData](100) q.status = make(chan struct{}, 100) q.videos = make(map[string]*inv.VideoData) q.table = tview.NewTable() q.table.SetContent(q) q.table.SetSelectable(true, false) q.table.SetInputCapture(q.Keybindings) q.table.SetBackgroundColor(tcell.ColorDefault) q.table.SetSelectionChangedFunc(q.selectorHandler) q.table.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextQueue, q.table) }) q.modal = app.NewModal("queue", "Queue", q.table, 40, 0) q.lock = semaphore.NewWeighted(1) q.init = true } // Show shows the player queue. func (q *Queue) Show() { if q.IsOpen() || q.Count() == 0 || !player.setting.Load() { return } q.modal.Show(true) q.sendStatus() } // Hide hides the player queue. func (q *Queue) Hide() { q.modal.Exit(false) } // IsOpen returns whether the queue is open. func (q *Queue) IsOpen() bool { return q.modal != nil && q.modal.Open } // Add adds an entry to the player queue. func (q *Queue) Add(video inv.VideoData, audio bool, uri ...string) { count := q.Count() _, _, w, _ := q.GetRect() media := "Audio" if !audio { media = "Video" } length := "Live" if !video.LiveNow { length = utils.FormatDuration(video.LengthSeconds) } video.MediaType = media q.SetData(count, QueueData{ Columns: [QueueColumnSize]*tview.TableCell{ tview.NewTableCell(" "). SetMaxWidth(1). SetSelectable(false), tview.NewTableCell("[blue::b]" + tview.Escape(video.Title)). SetExpansion(1). SetMaxWidth(w / 7). SetSelectable(true). SetSelectedStyle(app.UI.ColumnStyle), tview.NewTableCell(" "). SetMaxWidth(1). SetSelectable(false), tview.NewTableCell("[purple::b]" + tview.Escape(video.Author)). SetExpansion(1). SetMaxWidth(w / 7). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), tview.NewTableCell(" "). SetMaxWidth(1). SetSelectable(false), tview.NewTableCell(fmt.Sprintf(MediaMarkerFormat, media)). SetMaxWidth(5). SetSelectable(true). SetSelectedStyle(app.UI.ColumnStyle), tview.NewTableCell(" "). SetMaxWidth(1). SetSelectable(false), tview.NewTableCell("[pink::b]" + length). SetMaxWidth(10). SetSelectable(true). SetSelectedStyle(app.UI.ColumnStyle), tview.NewTableCell(" "). SetMaxWidth(11). SetSelectable(false), tview.NewTableCell(" "). SetMaxWidth(1). SetSelectable(false), }, Audio: audio, Reference: video, URI: uri, }) if count == 0 { q.SwitchToPosition(count) app.UI.QueueUpdateDraw(func() { q.SelectCurrentRow() }) } } // AutoPlay automatically selects what to play after // the current entry has finished playing. func (q *Queue) AutoPlay(force bool) { switch q.GetRepeatMode() { case mp.RepeatModeFile: return case mp.RepeatModePlaylist: if !q.shuffle.Load() && q.Position() == q.Count()-1 { q.SwitchToPosition(0) return } } q.Next() } // Play plays the entry at the current queue position. func (q *Queue) Play(norender ...struct{}) { go func() { if q.playcancel != nil { q.playcancel() } if q.playctx == nil || q.playctx.Err() == context.Canceled { q.playctx, q.playcancel = context.WithCancel(context.Background()) } data, ok := q.GetCurrent() if !ok { app.ShowError(fmt.Errorf("Player: Cannot get media data for %s", data.Reference.Title)) return } mp.Player().Stop() q.MarkPlayingEntry(EntryFetching) q.audio.Store(data.Audio) q.title.Store(data.Reference.Title) sendPlayerEvents() Show() video, uri, err := inv.RenewVideoURI(q.playctx, data.URI, data.Reference, data.Audio) if err != nil { if !errors.Is(err, context.Canceled) { q.MarkPlayingEntry(EntryStopped) app.ShowError(fmt.Errorf("Player: Cannot get media URI for %s", data.Reference.Title)) } return } q.SetReference(q.Position(), video, struct{}{}) q.MarkPlayingEntry(EntryLoading) if err := mp.Player().LoadFile( data.Reference.Title, data.Reference.LengthSeconds, data.Audio, uri..., ); err != nil { app.ShowError(err) return } mp.Player().Play() if norender == nil { renderInfo(data.Reference, struct{}{}) } }() } // Delete removes a entry from the specified position within the queue. func (q *Queue) Delete(position int) { q.storeMutex.Lock() defer q.storeMutex.Unlock() q.store.Remove(position) } // Move moves the position of the selected queue entry. func (q *Queue) Move(before, after int) { q.storeMutex.Lock() defer q.storeMutex.Unlock() length := q.store.Len() if (after < 0 || before < 0) || (after >= length || before >= length) { return } if q.Position() == before { q.SetPosition(after) } b := q.store.At(before) q.store.Remove(before) q.store.Insert(after, b) } // Count returns the number of items in the queue. func (q *Queue) Count() int { q.storeMutex.Lock() defer q.storeMutex.Unlock() return q.store.Len() } // Position returns the current position within the queue. func (q *Queue) Position() int { return int(q.position.Load()) } // SetPosition sets the current position within the queue. func (q *Queue) SetPosition(position int) { q.position.Store(int32(position)) } // SwitchToPosition switches to the specified position within the queue. func (q *Queue) SwitchToPosition(position int) { q.storeMutex.Lock() defer q.storeMutex.Unlock() data, ok := q.GetEntryPointer(position) if !ok { return } if q.current != nil { q.current.Playing = false } data.Playing = true data.HasPlayed = true q.current = data q.SetPosition(position) q.Play() } // SelectRecentEntry selects the recent-most entry in the queue. func (q *Queue) SelectRecentEntry() { q.SwitchToPosition(q.Count() - 1) } // Previous selects the previous entry from the current position in the queue. func (q *Queue) Previous(force ...struct{}) { length := q.Count() if length == 0 { return } position := q.Position() if q.Shuffle(position, length, force...) || position-1 < 0 { return } q.SwitchToPosition(position - 1) } // Next selects the next entry from the current position in the queue. func (q *Queue) Next(force ...struct{}) { length := q.Count() if length == 0 { return } position := q.Position() if q.Shuffle(position, length, force...) || position+1 >= length { return } q.SwitchToPosition(position + 1) } // Shuffle chooses and plays a random entry. func (q *Queue) Shuffle(position, count int, force ...struct{}) bool { if !q.shuffle.Load() { return false } skipped := 0 pos := -1 q.storeMutex.Lock() for skipped < count { for { pos = rand.Intn(count) if pos != position { break } } data, ok := q.GetEntryPointer(pos) if !ok { continue } if !data.HasPlayed { break } skipped++ } q.storeMutex.Unlock() if skipped >= count { q.storeMutex.Lock() q.store.Index(func(data *QueueData) bool { data.HasPlayed = false return false }) q.storeMutex.Unlock() if mode := q.GetRepeatMode(); mode == mp.RepeatModePlaylist || force != nil { q.Shuffle(position, count) } } else { q.SwitchToPosition(pos) } return true } // Get returns the entry data at the specified position from the queue. func (q *Queue) Get(position int) (QueueData, bool) { q.storeMutex.Lock() defer q.storeMutex.Unlock() data, ok := q.GetEntryPointer(position) if !ok { return QueueData{}, false } return *data, true } // GetEntryPointer returns a pointer to the entry data at the specified position from the queue. func (q *Queue) GetEntryPointer(position int) (*QueueData, bool) { length := q.store.Len() if position < 0 || position >= length { return nil, false } return q.store.At(position), true } // GetPlayingIndex returns the index of the currently playing entry. func (q *Queue) GetPlayingIndex() int { q.storeMutex.Lock() defer q.storeMutex.Unlock() return q.store.Index(func(d *QueueData) bool { return d.Playing }) } // GetCurrent returns the entry data at the current position from the queue. func (q *Queue) GetCurrent() (QueueData, bool) { return q.Get(q.Position()) } // GetTitle returns the title for the currently playing entry. func (q *Queue) GetTitle() string { var title string if t, ok := q.title.Load().(string); ok { title = t } return title } // GetMediaType returns the media type for the currently playing entry. func (q *Queue) GetMediaType() string { audio := q.audio.Load() if audio { return "Audio" } return "Video" } // GetRepeatMode returns the current repeat mode. func (q *Queue) GetRepeatMode() mp.RepeatMode { return mp.RepeatMode(int(q.repeat.Load())) } // GetShuffleMode returns the current shuffle mode. func (q *Queue) GetShuffleMode() bool { return q.shuffle.Load() } // GetCell returns a TableCell from the queue entry data at the specified row and column. func (q *Queue) GetCell(row, column int) *tview.TableCell { data, ok := q.Get(row) if !ok { return nil } return data.Columns[column] } // GetRowCount returns the number of rows in the table. func (q *Queue) GetRowCount() int { return q.Count() } // GetColumnCount returns the number of columns in the table. func (q *Queue) GetColumnCount() int { return QueueColumnSize - 1 } // SelectCurrentRow selects the specified row within the table. func (q *Queue) SelectCurrentRow(row ...int) { var pos int if row != nil { pos = row[0] } else { pos, _ = q.table.GetSelection() } q.table.Select(pos, 0) } // GetRect returns the dimensions of the table. func (q *Queue) GetRect() (int, int, int, int) { var x, y, w, h int app.UI.QueueUpdate(func() { x, y, w, h = q.table.GetRect() }) return x, y, w, h } // MarkPlayingEntry marks the current queue entry as 'playing/loading'. func (q *Queue) MarkPlayingEntry(status QueueEntryStatus) { pos := q.GetPlayingIndex() if pos < 0 { return } cell := q.GetCell(pos, QueuePlayingMarker) if cell == nil { return } app.UI.QueueUpdateDraw(func() { if q.marker != nil { q.marker.SetText("") } q.marker = cell color := "white" switch status { case EntryFetching, EntryLoading: color = "yellow" case EntryStopped: color = "red" } marker := string(status) q.marker.SetText(fmt.Sprintf(PlayerMarkerFormat, color, marker)) }) } // MarkEntryMediaType marks the selected queue entry as 'Audio/Video'. func (q *Queue) MarkEntryMediaType(key cmd.Key) { var media string q.storeMutex.Lock() defer q.storeMutex.Unlock() switch key { case cmd.KeyPlayerQueueAudio: media = "Audio" case cmd.KeyPlayerQueueVideo: media = "Video" default: return } audio := media == "Audio" pos, _ := q.table.GetSelection() data, ok := q.GetEntryPointer(pos) if !ok || data.Audio == audio { return } data.Audio = audio data.Columns[QueueMediaMarker].SetText( fmt.Sprintf(MediaMarkerFormat, media), ) if pos == q.Position() { q.Play(struct{}{}) } } // SetData sets/adds entry data in the queue. func (q *Queue) SetData(row int, data QueueData) { q.storeMutex.Lock() defer q.storeMutex.Unlock() length := q.store.Len() if length == 0 || row >= length { q.store.PushBack(&data) return } q.store.Set(row, &data) } // SetReference sets the reference for the data at the specified row in the queue. func (q *Queue) SetReference(row int, video inv.VideoData, checkID ...struct{}) { q.storeMutex.Lock() defer q.storeMutex.Unlock() data, ok := q.GetEntryPointer(row) if !ok || checkID != nil && data.Reference.VideoID != video.VideoID { return } data.Reference = video } // SetState sets the player states (repeat/shuffle). func (q *Queue) SetState(state string) { if state == "shuffle" { q.shuffle.Store(true) return } if strings.Contains(state, "loop") { repeatMode := statesMap[state] q.repeat.Store(int32(repeatMode)) mp.Player().SetLoopMode(mp.RepeatMode(repeatMode)) } } // Clear clears the queue. func (q *Queue) Clear() { q.storeMutex.Lock() defer q.storeMutex.Unlock() q.store.Clear() q.SetPosition(0) } // ToggleRepeatMode toggles the repeat mode. func (q *Queue) ToggleRepeatMode() { repeatMode := mp.RepeatMode(int(q.repeat.Load())) switch repeatMode { case mp.RepeatModeOff: repeatMode = mp.RepeatModeFile case mp.RepeatModeFile: repeatMode = mp.RepeatModePlaylist case mp.RepeatModePlaylist: repeatMode = mp.RepeatModeOff } q.repeat.Store(int32(repeatMode)) mp.Player().SetLoopMode(repeatMode) } // ToggleShuffle toggles the shuffle mode. func (q *Queue) ToggleShuffle() { shuffle := q.shuffle.Load() q.shuffle.Store(!shuffle) } // Context returns/cancels the queue's context. func (q *Queue) Context(cancel bool) context.Context { if cancel && q.ctx != nil { q.cancel() } if q.ctx == nil || q.ctx.Err() == context.Canceled { q.ctx, q.cancel = context.WithCancel(context.Background()) } return q.ctx } // LoadPlaylist loads the provided playlist into MPV. // If replace is true, the provided playlist will replace the current playing queue. // renewLiveURL is a function to check and renew expired liev URLs in the playlist. // //gocyclo:ignore func (q *Queue) LoadPlaylist(ctx context.Context, plpath string, replace bool) error { var filesAdded int if replace { q.Clear() } pl, err := os.Open(plpath) if err != nil { return fmt.Errorf("MPV: Unable to open %s", plpath) } defer pl.Close() playlist, err := m3u8.ReadFile(plpath) if err != nil { return err } uriMap := make(map[string]struct{}, len(playlist.Items)) ReadPlaylist: for _, item := range playlist.Items { var mediaURI string var audio bool video := inv.VideoData{ Title: "No title", } switch v := item.(type) { case *m3u8.SessionDataItem: if v.URI == nil || v.Value == nil { continue } if v.DataID == "" || !strings.HasPrefix(v.DataID, inv.PlaylistEntryPrefix) { continue } uri, err := utils.IsValidURL(*v.URI) if err != nil { return err } mediaURI = uri.String() if _, ok := uriMap[mediaURI]; ok { continue } uriMap[mediaURI] = struct{}{} vmap := make(map[string]string) if !utils.DecodeSessionData(*v.Value, func(prop, value string) { vmap[prop] = value }) { continue } for _, prop := range []string{ "id", "authorId", "mediatype", } { if vmap[prop] == "" { continue ReadPlaylist } } audio = vmap["mediatype"] == "Audio" title, _ := url.QueryUnescape(vmap["title"]) author, _ := url.QueryUnescape(vmap["author"]) length := vmap["length"] video.Title = title video.Author = author video.AuthorID = vmap["authorId"] video.VideoID = vmap["id"] video.MediaType = vmap["mediatype"] video.LiveNow = length == "Live" video.LengthSeconds = utils.ConvertDurationToSeconds(vmap["length"]) case *m3u8.SegmentItem: var live bool mediaURI = v.Segment if strings.HasPrefix(mediaURI, "#") { continue } if _, ok := uriMap[mediaURI]; ok { continue } uri, err := utils.IsValidURL(mediaURI) if err != nil { return err } audio = true uriMap[mediaURI] = struct{}{} data := uri.Query() if data.Get("id") == "" { id, _ := inv.CheckLiveURL(mediaURI, audio) if id == "" { continue } data.Set("id", id) live = true } if v.Comment != nil { data.Set("title", *v.Comment) } for _, d := range []string{"title", "author"} { if data.Get(d) == "" { data.Set(d, "-") } } video.VideoID = data.Get("id") video.Title = data.Get("title") video.Author = data.Get("author") video.LiveNow = live video.MediaType = "Audio" video.LengthSeconds = int64(v.Duration) default: continue } if video.LiveNow { video.HlsURL = mediaURI } q.Add(video, audio, mediaURI) filesAdded++ } return nil } // Keybindings define the keybindings for the queue. func (q *Queue) Keybindings(event *tcell.EventKey) *tcell.EventKey { operation := cmd.KeyOperation(event, cmd.KeyContextQueue) for _, op := range []cmd.Key{ cmd.KeyClose, cmd.KeyQueueSave, } { if operation == op { q.Hide() break } } switch operation { case cmd.KeyQueuePlayMove: q.play() case cmd.KeyQueueSave: app.UI.FileBrowser.Show("Save as:", q.saveAs) case cmd.KeyQueueAppend: app.UI.FileBrowser.Show("Append from:", q.appendFrom) case cmd.KeyQueueDelete: q.remove() case cmd.KeyQueueMove: q.move() case cmd.KeyPlayerQueueAudio, cmd.KeyPlayerQueueVideo: q.MarkEntryMediaType(operation) case cmd.KeyPlayerStop, cmd.KeyClose: q.Hide() } for _, o := range []cmd.Key{ cmd.KeyQueueMove, cmd.KeyQueueDelete, } { if operation == o { app.ResizeModal() break } } return event } // play handles the 'Enter' key event within the queue. // If the move mode is enabled, the currently moving item // is set to the position where the selector rests. // Otherwise, it plays the currently selected queue item. func (q *Queue) play() { row, _ := q.table.GetSelection() if q.moveMode { selected := row q.Move(q.prevrow, row) q.moveMode = false q.table.Select(selected, 0) return } q.SwitchToPosition(row) } // remove handles the 'd' key within the queue. // It deletes the currently selected queue item. func (q *Queue) remove() { rows := q.table.GetRowCount() - 1 row, _ := q.table.GetSelection() q.Delete(row) switch { case rows <= 0: player.setting.Store(false) q.Clear() q.Hide() go Hide() return case row >= rows: row = rows - 1 } q.SelectCurrentRow(row) pos := q.GetPlayingIndex() if pos < 0 { if row > rows { return } pos = row q.SwitchToPosition(row) } q.SetPosition(pos) if pos == row { sendPlayerEvents() } } // move handles the 'M' key within the queue. // It enables the move mode, and starts moving the selected entry. func (q *Queue) move() { q.prevrow, _ = q.table.GetSelection() q.moveMode = true q.table.Select(q.prevrow, 0) } // selectorHandler checks whether the move mode is enabled or not, // and displays the appropriate selector indicator within the queue. func (q *Queue) selectorHandler(row, col int) { selector := ">" rows := q.table.GetRowCount() if q.moveMode { selector = "M" } for i := 0; i < rows; i++ { cell := q.table.GetCell(i, 0) if cell == nil { cell = tview.NewTableCell(" ") q.table.SetCell(i, 0, cell) } if i == row { cell.SetText(selector) continue } cell.SetText(" ") } } // saveAs saves the current queue into a playlist M3U8 file. func (q *Queue) saveAs(file string) { if !q.lock.TryAcquire(1) { app.ShowInfo("Playlist save in progress", false) } defer q.lock.Release(1) var videos []inv.VideoData for i := 0; i < q.Count(); i++ { data, ok := q.Get(i) if !ok { continue } v := data.Reference if v.VideoID != "" { videos = append(videos, v) } } if len(videos) == 0 { return } app.UI.FileBrowser.SaveFile(file, func(flags int, appendToFile bool) (string, int, error) { return inv.GeneratePlaylist(file, videos, flags, appendToFile) }) } // appendFrom appends the entries from the provided playlist file // into the currently playing queue. func (q *Queue) appendFrom(file string) { app.ShowInfo("Loading "+filepath.Base(file), true) err := q.LoadPlaylist(q.Context(false), file, false) if err != nil { app.ShowError(err) return } app.UI.QueueUpdateDraw(func() { player.queue.Show() app.UI.FileBrowser.Hide() }) app.ShowInfo("Loaded "+filepath.Base(file), false) } // sendStatus sends status events to the queue. func (q *Queue) sendStatus() { select { case q.status <- struct{}{}: default: } } invidtui-0.3.7/ui/player/states.go000066400000000000000000000014111454311651000171140ustar00rootroot00000000000000package player import ( "strings" "github.com/darkhz/invidtui/cmd" mp "github.com/darkhz/invidtui/mediaplayer" ) var statesMap = map[string]int{ "loop-file": int(mp.RepeatModeFile), "loop-playlist": int(mp.RepeatModePlaylist), } // loadState loads the saved player states. func loadState() { states := cmd.Settings.PlayerStates if len(states) == 0 { return } for _, s := range states { for _, state := range []string{ "volume", "mute", "loop", "shuffle", } { if !strings.Contains(s, state) { continue } switch state { case "volume": vol := strings.Split(s, " ")[1] mp.Player().Set("volume", vol) case "mute": mp.Player().ToggleMuted() case "loop", "shuffle": player.queue.SetState(s) } } } } invidtui-0.3.7/ui/popup/000077500000000000000000000000001454311651000151345ustar00rootroot00000000000000invidtui-0.3.7/ui/popup/instances.go000066400000000000000000000050101454311651000174460ustar00rootroot00000000000000package popup import ( "strings" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // ShowInstancesList shows a popup with a list of instances. func ShowInstancesList() { var instancesModal *app.Modal app.ShowInfo("Loading instance list", true) instances, err := client.GetInstances() if err != nil { app.ShowError(err) return } instancesView := tview.NewTable() instancesView.SetSelectorWrap(true) instancesView.SetSelectable(true, false) instancesView.SetBackgroundColor(tcell.ColorDefault) instancesView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter: row, _ := instancesView.GetSelection() if instance, ok := instancesView.GetCell(row, 0).GetReference().(string); ok { go checkInstance(instance, instancesView) } case tcell.KeyEscape: instancesModal.Exit(false) } return event }) instancesView.SetFocusFunc(func() { app.SetContextMenu("", nil) }) app.UI.QueueUpdateDraw(func() { var width int currentInstance := utils.GetHostname(client.Instance()) for row, instance := range instances { if instance == currentInstance { instance += " [white::b](Selected)[-:-:-]" } if len(instance) > width { width = len(instance) } instancesView.SetCell(row, 0, tview.NewTableCell(instance). SetReference(instances[row]). SetTextColor(tcell.ColorBlue). SetSelectedStyle(app.UI.SelectedStyle), ) } instancesModal = app.NewModal("instances", "Available instances", instancesView, len(instances)+4, width+4) instancesModal.Show(false) }) app.ShowInfo("Instances loaded", false) } // checkInstance checks the instance. func checkInstance(instance string, table *tview.Table) { if instance == utils.GetHostname(client.Instance()) { return } app.ShowInfo("Checking "+instance, true) instURL, err := client.CheckInstance(instance) if err != nil { app.ShowError(err) return } client.SetHost(instURL) app.UI.QueueUpdateDraw(func() { var cell *tview.TableCell for i := 0; i < table.GetRowCount(); i++ { c := table.GetCell(i, 0) if ref, ok := c.GetReference().(string); ok { if ref == instance { cell = c } text := c.Text if strings.Contains(text, "Selected") || strings.Contains(text, "Changed") { c.SetText(ref) } } } cell.SetText(instance + " [white::b](Changed)[-:-:-]") }) app.ShowInfo("Set client to "+instance, false) } invidtui-0.3.7/ui/popup/link.go000066400000000000000000000031661454311651000164260ustar00rootroot00000000000000package popup import ( "github.com/darkhz/invidtui/client" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // ShowLink shows a popup with Invidious and Youtube // links for the currently selected video/playlist/channel entry. func ShowLink() { var linkModal *app.Modal info, err := app.FocusedTableReference() if err != nil { app.ShowError(err) return } invlink, ytlink := getLinks(info) linkText := "[::u]Invidious link[-:-:-]\n[::b]" + invlink + "\n\n[::u]Youtube link[-:-:-]\n[::b]" + ytlink linkView := tview.NewTextView() linkView.SetText(linkText) linkView.SetDynamicColors(true) linkView.SetBackgroundColor(tcell.ColorDefault) linkView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter, tcell.KeyEscape: linkModal.Exit(false) } return event }) linkView.SetFocusFunc(func() { app.SetContextMenu("", nil) }) linkModal = app.NewModal("link", "Copy link", linkView, 10, len(invlink)+10) linkModal.Show(false) } // getLinks returns the Invidious and Youtube links // according to the currently selected entry's type (video/playlist/channel). func getLinks(info inv.SearchData) (string, string) { var linkparam string invlink := client.Instance() ytlink := "https://youtube.com" switch info.Type { case "video": linkparam = "/watch?v=" + info.VideoID case "playlist": linkparam = "/playlist?list=" + info.PlaylistID case "channel": linkparam = "/channel/" + info.AuthorID } invlink += linkparam ytlink += linkparam return invlink, ytlink } invidtui-0.3.7/ui/ui.go000066400000000000000000000047561454311651000147510ustar00rootroot00000000000000package ui import ( "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/cmd" mp "github.com/darkhz/invidtui/mediaplayer" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/ui/menu" "github.com/darkhz/invidtui/ui/player" "github.com/darkhz/invidtui/ui/popup" "github.com/darkhz/invidtui/ui/view" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // SetupUI sets up the UI and starts the application. func SetupUI() { app.Setup() app.InitMenu(menu.Items) app.SetResizeHandler(Resize) app.SetGlobalKeybindings(Keybindings) instance := utils.GetHostname(client.Instance()) msg := "Instance '" + instance + "' selected. " msg += "Press / to search." app.ShowInfo(msg, true) go detectPlayerClose() player.ParseQuery() view.Search.ParseQuery() player.Start() view.SetView(&view.Banner) _, focusedItem := app.UI.Pages.GetFrontPage() if err := app.UI.SetRoot(app.UI.Area, true).SetFocus(focusedItem).Run(); err != nil { cmd.PrintError("UI: Could not start", err) } } // StopUI stops the application. func StopUI(skip ...struct{}) { app.Stop(skip...) player.Stop() } // Resize handles the resizing of the app and its components. func Resize(screen tcell.Screen) { width, _ := screen.Size() app.ResizeModal() player.Resize(width) } // Keybindings defines the global keybindings for the application. func Keybindings(event *tcell.EventKey) *tcell.EventKey { operation := cmd.KeyOperation(event, cmd.KeyContextApp, cmd.KeyContextDashboard, cmd.KeyContextDownloads) focused := app.UI.GetFocus() if _, ok := focused.(*tview.InputField); ok && operation != "Menu" { goto Event } if player.Keybindings(event) == nil { return nil } switch operation { case cmd.KeyMenu: app.FocusMenu() return nil case cmd.KeyDashboard: view.Dashboard.EventHandler() case cmd.KeySuspend: app.UI.Suspend = true case cmd.KeyCancel: client.Cancel() client.SendCancel() view.Comments.Close() app.ShowInfo("Loading canceled", false) case cmd.KeyDownloadView: view.Downloads.View() case cmd.KeyDownloadOptions: go view.Downloads.ShowOptions() case cmd.KeyInstancesList: go popup.ShowInstancesList() case cmd.KeyQuit: StopUI() } Event: return event } // detectPlayerClose detects if the player has exited abruptly. func detectPlayerClose() { mp.Player().WaitClosed() mp.Player().Exit() select { case <-app.UI.Closed.Done(): return default: } StopUI(struct{}{}) cmd.PrintError("Player has exited") } invidtui-0.3.7/ui/view/000077500000000000000000000000001454311651000147435ustar00rootroot00000000000000invidtui-0.3.7/ui/view/banner.go000066400000000000000000000044121454311651000165400ustar00rootroot00000000000000package view import ( "strings" "github.com/darkhz/invidtui/cmd" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) const bannerText = ` (_)____ _ __ (_)____/ // /_ __ __ (_) / // __ \| | / // // __ // __// / / // / / // / / /| |/ // // /_/ // /_ / /_/ // / /_//_/ /_/ |___//_/ \__,_/ \__/ \__,_//_/ ` // BannerView describes the layout of a banner view. type BannerView struct { flex *tview.Flex init, shown bool } // Banner stores the banner view properties. var Banner BannerView // Name returns the name of the banner view. func (b *BannerView) Name() string { return "Start" } // Init intializes the banner view. func (b *BannerView) Init() bool { if b.init { return true } b.shown = true b.setup() b.init = true return true } // Exit closes the banner view. func (b *BannerView) Exit() bool { b.shown = false return true } // Tabs describes the tab layout for the banner view. func (b *BannerView) Tabs() app.Tab { return app.Tab{} } // Primitive returns the primitive for the banner view. func (b *BannerView) Primitive() tview.Primitive { return b.flex } // Keybindings describes the banner view's keybindings. func (b *BannerView) Keybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event) { case cmd.KeyQuery: Search.Query() } return event } // setup sets up the banner view. func (b *BannerView) setup() { lines := strings.Split(bannerText, "\n") bannerWidth := 0 bannerHeight := len(lines) bannerBox := tview.NewTextView() bannerBox.SetDynamicColors(true) bannerBox.SetBackgroundColor(tcell.ColorDefault) bannerBox.SetText("[::b]" + bannerText) box := tview.NewBox(). SetBackgroundColor(tcell.ColorDefault) for _, line := range lines { if len(line) > bannerWidth { bannerWidth = len(line) } } b.flex = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(box, 0, 7, false). AddItem(tview.NewFlex(). AddItem(box, 0, 1, false). AddItem(bannerBox, bannerWidth, 1, true). AddItem(box, 0, 1, false), bannerHeight, 1, true). AddItem(box, 0, 7, false) b.flex.SetBackgroundColor(tcell.ColorDefault) b.flex.SetInputCapture(b.Keybindings) bannerBox.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextStart, b.flex) }) b.shown = true } invidtui-0.3.7/ui/view/channel.go000066400000000000000000000310421454311651000167020ustar00rootroot00000000000000package view import ( "fmt" "strconv" "sync" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/ui/popup" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" "golang.org/x/sync/semaphore" ) // ChannelView describes the layout of a channel view. type ChannelView struct { init bool searchText, currentID, currentType string continuation map[string]*ChannelContinuation infoView InfoView views *tview.Pages tableMap map[string]*ChannelTable lock *semaphore.Weighted mutex sync.Mutex } // ChannelTable describes the properties of a channel table. type ChannelTable struct { loaded bool table *tview.Table } // ChannelContinuation describes the page/continuation data // for the channel table. type ChannelContinuation struct { loaded bool page int continuation string } // Channel stores the channel view properties. var Channel ChannelView // Name returns the name of the channel view. func (c *ChannelView) Name() string { return "Channel" } // Init initializes the channel view. func (c *ChannelView) Init() bool { if c.init { return true } c.continuation = make(map[string]*ChannelContinuation) for _, i := range c.Tabs().Info { c.continuation[i.ID] = &ChannelContinuation{} } c.views = tview.NewPages() c.views.SetBackgroundColor(tcell.ColorDefault) c.queueWrite(func() { c.tableMap = make(map[string]*ChannelTable) for _, info := range c.Tabs().Info { table := tview.NewTable() table.SetSelectorWrap(true) table.SetSelectable(true, false) table.SetInputCapture(c.Keybindings) table.SetBackgroundColor(tcell.ColorDefault) table.SetSelectionChangedFunc(func(row, col int) { c.selectorHandler(table, row, col) }) table.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextChannel, c.views) }) c.tableMap[info.Title] = &ChannelTable{ table: table, } c.views.AddPage(info.Title, table, true, false) } }) c.infoView.Init(c.views) c.lock = semaphore.NewWeighted(1) c.init = true return true } // Exit closes the channel view func (c *ChannelView) Exit() bool { return true } // Tabs describes the tab layout for the channel view. func (c *ChannelView) Tabs() app.Tab { return app.Tab{ Title: "Channel", Info: []app.TabInfo{ {ID: "video", Title: "Videos"}, {ID: "playlist", Title: "Playlists"}, {ID: "search", Title: "Search"}, }, Selected: c.currentType, } } // Primitive returns the primitive for the channel view. func (c *ChannelView) Primitive() tview.Primitive { return c.infoView.flex } // View shows the channel view. func (c *ChannelView) View(pageType string) { if c.infoView.flex == nil || c.infoView.flex.GetItemCount() == 0 { return } SetView(&Channel) app.SelectTab(pageType) c.currentType = pageType for _, i := range c.Tabs().Info { if i.ID == pageType { c.views.SwitchToPage(i.Title) app.UI.SetFocus(c.getTableMap()[i.Title].table) break } } } // EventHandler shows the channel view according to the provided page type. func (c *ChannelView) EventHandler(pageType string, justView bool) { if justView { c.View(pageType) return } c.Init() info, err := app.FocusedTableReference() if err != nil { app.ShowError(err) return } c.queueWrite(func() { c.currentID = info.AuthorID for _, i := range c.Tabs().Info { ct := c.tableMap[i.Title] ct.table.Clear() ct.loaded = false } }) go c.Load(pageType) } // Load loads the channel view according to the page type. // //gocyclo:ignore func (c *ChannelView) Load(pageType string, loadMore ...struct{}) { var err error var author, description string if !c.lock.TryAcquire(1) { app.ShowError(fmt.Errorf("View: Channel: Still loading data")) return } defer c.lock.Release(1) if loadMore == nil { for _, i := range c.Tabs().Info { if i.ID != pageType { continue } if c.getTableMap()[i.Title].loaded { goto RenderView } break } } switch pageType { case "video": author, description, err = c.Videos(c.currentID, loadMore...) case "playlist": author, description, err = c.Playlists(c.currentID, loadMore...) case "search": err = nil } if err != nil { app.ShowError(err) return } RenderView: app.UI.QueueUpdateDraw(func() { if GetCurrentView() != &Channel && author != "" { c.infoView.Set(author, description) } if GetCurrentView() != &Channel || app.GetCurrentTab() != pageType { c.View(pageType) } if pageType == "search" { if loadMore == nil { c.Query() } else { go c.Search("") } } }) } // Videos loads the channel videos. func (c *ChannelView) Videos(id string, loadMore ...struct{}) (string, string, error) { emptyVideoErr := fmt.Errorf("View: Channel: No more video results in channel") videoContinuation := c.continuation["video"] if loadMore == nil { videoContinuation.loaded = false videoContinuation.continuation = "" } if videoContinuation.loaded { app.ShowError(emptyVideoErr) return "", "", emptyVideoErr } app.ShowInfo("Loading Channel videos", true) result, err := inv.ChannelVideos(id, videoContinuation.continuation) if err != nil { app.ShowError(err) return "", "", err } if len(result.Videos) == 0 { app.ShowError(emptyVideoErr) return "", "", emptyVideoErr } if result.Continuation == "" { videoContinuation.loaded = true } videoContinuation.continuation = result.Continuation app.UI.QueueUpdateDraw(func() { var skipped int pos := -1 _, _, pageWidth, _ := app.UI.Pages.GetRect() videoMap := c.getTableMap()["Videos"] videoTable := videoMap.table rows := videoTable.GetRowCount() for i, v := range result.Videos { select { case <-client.Ctx().Done(): return default: } if pos < 0 { pos = (rows + i) - skipped } if v.LengthSeconds == 0 { skipped++ continue } sref := inv.SearchData{ Type: "video", Title: v.Title, VideoID: v.VideoID, AuthorID: result.ChannelID, Author: result.Author, } videoTable.SetCell((rows+i)-skipped, 0, tview.NewTableCell("[blue::b]"+tview.Escape(v.Title)). SetExpansion(1). SetReference(sref). SetMaxWidth((pageWidth / 4)). SetSelectedStyle(app.UI.SelectedStyle), ) videoTable.SetCell((rows+i)-skipped, 1, tview.NewTableCell("[pink]"+utils.FormatDuration(v.LengthSeconds)). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } c.queueWrite(func() { videoMap.loaded = true }) }) app.ShowInfo("Video entries loaded", false) return result.Author, result.Description, nil } // Playlists loads the channel playlists. func (c *ChannelView) Playlists(id string, loadMore ...struct{}) (string, string, error) { emptyPlaylistErr := fmt.Errorf("View: Channel: No more playlist results in channel") playlistContinuation := c.continuation["playlist"] if loadMore == nil { playlistContinuation.loaded = false playlistContinuation.continuation = "" } if playlistContinuation.loaded { app.ShowError(emptyPlaylistErr) return "", "", emptyPlaylistErr } app.ShowInfo("Loading Channel playlists", true) result, err := inv.ChannelPlaylists(id, playlistContinuation.continuation) if err != nil { return "", "", err } if len(result.Playlists) == 0 { app.ShowError(emptyPlaylistErr) return "", "", emptyPlaylistErr } if result.Continuation == "" { playlistContinuation.loaded = true } playlistContinuation.continuation = result.Continuation app.UI.QueueUpdateDraw(func() { pos := -1 _, _, pageWidth, _ := app.UI.Pages.GetRect() playlistMap := c.getTableMap()["Playlists"] playlistTable := playlistMap.table rows := playlistTable.GetRowCount() for i, p := range result.Playlists { select { case <-client.Ctx().Done(): return default: } if pos < 0 { pos = (rows + i) } sref := inv.SearchData{ Type: "playlist", Title: p.Title, PlaylistID: p.PlaylistID, AuthorID: result.ChannelID, Author: result.Author, } playlistTable.SetCell((rows + i), 0, tview.NewTableCell("[blue::b]"+tview.Escape(p.Title)). SetExpansion(1). SetReference(sref). SetMaxWidth((pageWidth / 4)). SetSelectedStyle(app.UI.SelectedStyle), ) playlistTable.SetCell((rows + i), 1, tview.NewTableCell("[pink]"+strconv.FormatInt(p.VideoCount, 10)+" videos"). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } c.queueWrite(func() { playlistMap.loaded = true }) }) app.ShowInfo("Playlist entries loaded", false) return result.Author, result.Description, nil } // Search searches for the provided query within the channel. func (c *ChannelView) Search(text string) { searchContinuation := c.continuation["search"] if text == "" { if c.searchText == "" { return } text = c.searchText } else { c.searchText = text searchContinuation.page = 0 } app.ShowInfo("Fetching search results for "+tview.Escape(c.searchText), true) results, page, err := inv.ChannelSearch( c.currentID, c.searchText, searchContinuation.page, ) if err != nil { app.ShowError(err) return } if results == nil { err := fmt.Errorf("View: Channel: No more search results") app.ShowError(err) return } searchContinuation.page = page app.UI.QueueUpdateDraw(func() { pos := -1 searchMap := c.getTableMap()["Search"] searchTable := searchMap.table if text != "" { searchTable.Clear() } rows := searchTable.GetRowCount() _, _, width, _ := searchTable.GetRect() for i, result := range results { if pos < 0 { pos = rows + i } if result.Title == "" { result.Title = result.Author result.Author = "" } searchTable.SetCell(rows+i, 0, tview.NewTableCell("[blue::b]"+tview.Escape(result.Title)). SetExpansion(1). SetReference(result). SetMaxWidth((width / 4)). SetSelectedStyle(app.UI.SelectedStyle), ) searchTable.SetCell(rows+i, 1, tview.NewTableCell(" "). SetSelectable(false). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) searchTable.SetCell(rows+i, 2, tview.NewTableCell("[pink]"+result.Type). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } searchTable.Select(pos, 0) searchTable.ScrollToEnd() searchTable.SetSelectable(true, false) c.queueWrite(func() { searchMap.loaded = true }) }) app.ShowInfo("Fetched search results", false) } // Query prompts for a query and searches the channel. func (c *ChannelView) Query() { c.Init() label := "[::b]Search channel:" app.UI.Status.SetInput(label, 0, false, c.Search, c.inputFunc) } // Keybindings describes the keybindings for the channel view. func (c *ChannelView) Keybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event) { case cmd.KeySwitchTab: tab := c.Tabs() tab.Selected = c.currentType c.currentType = app.SwitchTab(false, tab) c.View(c.currentType) go c.Load(c.currentType) case cmd.KeyLoadMore: go c.Load(c.currentType, struct{}{}) case cmd.KeyClose: CloseView() case cmd.KeyQuery: c.currentType = "search" go c.Load(c.currentType) case cmd.KeyPlaylist: go Playlist.EventHandler(event.Modifiers() == tcell.ModAlt, false) case cmd.KeyAdd: Dashboard.ModifyHandler(true) case cmd.KeyComments: Comments.Show() case cmd.KeyLink: popup.ShowLink() } return event } // inputFunc describes the keybindings for the search input area. func (c *ChannelView) inputFunc(e *tcell.EventKey) *tcell.EventKey { switch e.Key() { case tcell.KeyEnter: go c.Search(app.UI.Status.GetText()) fallthrough case tcell.KeyEscape: app.UI.Status.Pages.SwitchToPage("messages") app.SetPrimaryFocus() } return e } // selectorHandler sets the attributes for the currently selected entry. func (c *ChannelView) selectorHandler(table *tview.Table, row, col int) { rows := table.GetRowCount() if row < 0 || row > rows { return } cell := table.GetCell(row, col) if cell == nil { return } table.SetSelectedStyle(tcell.Style{}. Background(tcell.ColorBlue). Foreground(tcell.ColorWhite). Attributes(cell.Attributes | tcell.AttrBold)) } // getTableMap returns a map of tables within the channel view. func (c *ChannelView) getTableMap() map[string]*ChannelTable { c.mutex.Lock() defer c.mutex.Unlock() return c.tableMap } // queueWrite executes the given function thread-safely. func (c *ChannelView) queueWrite(write func()) { c.mutex.Lock() defer c.mutex.Unlock() write() } invidtui-0.3.7/ui/view/comments.go000066400000000000000000000132571454311651000171270ustar00rootroot00000000000000package view import ( "strconv" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" "golang.org/x/sync/semaphore" ) // CommentsView describes the layout for a comments view. type CommentsView struct { init bool currentID string modal *app.Modal view *tview.TreeView root *tview.TreeNode lock *semaphore.Weighted } // Comments stores the properties of the comments view. var Comments CommentsView // Init initializes the comments view. func (c *CommentsView) Init() { c.view = tview.NewTreeView() c.view.SetGraphics(false) c.view.SetBackgroundColor(tcell.ColorDefault) c.view.SetSelectedStyle(tcell.Style{}. Foreground(tcell.Color16). Background(tcell.ColorWhite), ) c.view.SetSelectedFunc(c.selectorHandler) c.view.SetInputCapture(c.Keybindings) c.view.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextComments, c.view) }) c.root = tview.NewTreeNode("") c.modal = app.NewModal("comments", "Comments", c.view, 40, 0) c.lock = semaphore.NewWeighted(1) c.init = true } // Show shows the comments view. func (c *CommentsView) Show() { info, err := app.FocusedTableReference() if err != nil { app.ShowError(err) return } if info.Type != "video" { return } c.Init() go c.Load(info.VideoID, info.Title) } // Load loads the comments from the given video. func (c *CommentsView) Load(id, title string) { if !c.lock.TryAcquire(1) { app.ShowInfo("Comments are still loading", false) return } defer c.lock.Release(1) app.ShowInfo("Loading comments", true) comments, err := inv.Comments(id) if err != nil { app.ShowError(err) return } c.currentID = id app.ShowInfo("Loaded comments", false) app.UI.QueueUpdateDraw(func() { c.root.SetText("[blue::bu]" + title) for _, comment := range comments.Comments { c.addComment(c.root, comment) } c.addContinuation(c.root, comments.Continuation) c.view.SetRoot(c.root) c.view.SetCurrentNode(c.root) c.modal.Show(true) app.UI.SetFocus(c.view) }) } // Subcomments loads the subcomments for the currently selected comment. func (c *CommentsView) Subcomments(selected, removed *tview.TreeNode, continuation string) { if !c.lock.TryAcquire(1) { app.ShowInfo("Comments are still loading", false) return } defer c.lock.Release(1) showNode := selected if removed != nil { showNode = removed } app.UI.QueueUpdateDraw(func() { showNode.SetText("-- Loading comments --") }) subcomments, err := inv.Comments(c.currentID, continuation) if err != nil { app.ShowError(err) app.UI.QueueUpdateDraw(func() { selected.SetText("-- Reload --") }) return } app.UI.QueueUpdateDraw(func() { for i, comment := range subcomments.Comments { current := c.addComment(selected, comment) if i == 0 { c.view.SetCurrentNode(current) } } showNode.SetText("-- Hide comments --") if removed != nil { selected.RemoveChild(removed) } c.addContinuation(selected, subcomments.Continuation) }) } // Close closes the comments view. func (c *CommentsView) Close() { if c.modal == nil { return } c.modal.Exit(false) app.SetPrimaryFocus() } // Keybindings describes the keybindings for the comments view. func (c *CommentsView) Keybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event, cmd.KeyContextComments) { case cmd.KeyCommentReplies: node := c.view.GetCurrentNode() if node.GetLevel() > 2 { node.GetParent().SetExpanded(!node.GetParent().IsExpanded()) } case cmd.KeyClose: c.Close() } return event } // selectorHandler generates subcomments for the selected comment. func (c *CommentsView) selectorHandler(node *tview.TreeNode) { var selectedNode, removeNode *tview.TreeNode continuation, ok := node.GetReference().(string) if !ok { return } if node.GetLevel() == 2 && len(node.GetChildren()) > 0 { var toggle string expanded := node.IsExpanded() if expanded { toggle = "Show" } else { toggle = "Hide" } node.SetExpanded(!expanded) node.SetText("-- " + toggle + " comments --") return } if node.GetLevel() > 2 || node.GetParent() == c.root { selectedNode = node.GetParent() removeNode = node } else { selectedNode = node } go c.Subcomments(selectedNode, removeNode, continuation) } // addComments adds the provided comment to the comment node. func (c *CommentsView) addComment(node *tview.TreeNode, comment inv.CommentData) *tview.TreeNode { authorInfo := "- [purple::bu]" + comment.Author + "[-:-:-]" authorInfo += " [grey::b]" + utils.FormatPublished(comment.PublishedText) + "[-:-:-]" if comment.Verified { authorInfo += " [aqua::b](Verified)[-:-:-]" } if comment.AuthorIsChannelOwner { authorInfo += " [plum::b](Owner)" } authorInfo += " [red::b](" + strconv.Itoa(comment.LikeCount) + " likes)" commentNode := tview.NewTreeNode(authorInfo) for _, line := range utils.SplitLines(comment.Content) { commentNode.AddChild( tview.NewTreeNode(" " + line). SetSelectable(false). SetIndent(1), ) } if comment.Replies.ReplyCount > 0 { commentNode.AddChild( tview.NewTreeNode("-- Load " + strconv.Itoa(comment.Replies.ReplyCount) + " replies --"). SetReference(comment.Replies.Continuation), ) } node.AddChild(commentNode) node.AddChild( tview.NewTreeNode(""). SetSelectable(false), ) return commentNode } // addContinuation adds a button under a comments to load more subcomments. func (c *CommentsView) addContinuation(node *tview.TreeNode, continuation string) { if continuation == "" { return } node.AddChild( tview.NewTreeNode("-- Load more replies --"). SetSelectable(true). SetReference(continuation), ) } invidtui-0.3.7/ui/view/dashboard.go000066400000000000000000000467721454311651000172410ustar00rootroot00000000000000package view import ( "fmt" "strconv" "sync" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/ui/popup" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" "golang.org/x/sync/semaphore" ) // DashboardView describes the layout for a dashboard view. type DashboardView struct { init, auth bool currentType string modifyMap map[string]*semaphore.Weighted message *tview.TextView token *tview.InputField views *tview.Pages flex *tview.Flex tableMap map[string]*DashboardTable lock *semaphore.Weighted mutex sync.Mutex } // DashboardTable describes the properties of a dashboard table. type DashboardTable struct { loaded bool page int table *tview.Table } // Dashboard stores the dashboard view properties. var Dashboard DashboardView // Name returns the name of the dashboard view. func (d *DashboardView) Name() string { return "Dashboard" } // Init initializes the dashboard view. func (d *DashboardView) Init() bool { if d.init { return true } d.currentType = "feed" d.message = tview.NewTextView() d.message.SetWrap(true) d.message.SetDynamicColors(true) d.message.SetBackgroundColor(tcell.ColorDefault) d.token = tview.NewInputField() d.token.SetLabel("[white::b]Token: ") d.token.SetBackgroundColor(tcell.ColorDefault) d.token.SetFocusFunc(func() { app.SetContextMenu("", nil) }) d.flex = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(d.message, 10, 0, false). AddItem(nil, 1, 0, false). AddItem(d.token, 6, 0, true) d.views = tview.NewPages() d.views.SetBackgroundColor(tcell.ColorDefault) d.views.AddPage("Authentication", d.flex, true, false) d.queueWrite(func() { d.tableMap = make(map[string]*DashboardTable) kbMap := map[string]func(e *tcell.EventKey) *tcell.EventKey{ "Feed": d.feedKeybindings, "Playlists": d.plKeybindings, "Subscriptions": d.subKeybindings, } for _, info := range d.Tabs().Info { table := tview.NewTable() table.SetTitle(info.Title) table.SetSelectorWrap(true) table.SetBackgroundColor(tcell.ColorDefault) table.SetInputCapture(kbMap[info.Title]) table.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextDashboard, table) }) d.tableMap[info.Title] = &DashboardTable{ table: table, } d.views.AddPage(info.Title, table, true, false) } }) d.modifyMap = make(map[string]*semaphore.Weighted) for _, mt := range []string{ "video", "playlist", "channel", } { d.modifyMap[mt] = semaphore.NewWeighted(1) } d.lock = semaphore.NewWeighted(1) d.init = true return true } // Exit closes the dashboard view. func (d *DashboardView) Exit() bool { return true } // Tabs describes the tab layout for the dashboard view. func (d *DashboardView) Tabs() app.Tab { tab := app.Tab{Title: "Dashboard"} if d.auth { tab.Selected = "auth" tab.Info = []app.TabInfo{ {ID: "auth", Title: "Authentication"}, } } else { tab.Selected = d.currentType tab.Info = []app.TabInfo{ {ID: "feed", Title: "Feed"}, {ID: "playlists", Title: "Playlists"}, {ID: "subscriptions", Title: "Subscriptions"}, } } return tab } // Primitive returns the primitive for the dashboard view. func (d *DashboardView) Primitive() tview.Primitive { return d.views } // CurrentPage returns the dashboard's current page. func (d *DashboardView) CurrentPage(page ...string) string { d.mutex.Lock() defer d.mutex.Unlock() if page != nil { d.currentType = page[0] } return d.currentType } // IsFocused returns if the dashboard view is focused or not. func (d *DashboardView) IsFocused() bool { return d.views != nil && d.views.HasFocus() } // View shows the dashboard view. func (d *DashboardView) View(auth ...struct{}) { if d.views == nil { return } d.auth = auth != nil SetView(&Dashboard) if auth != nil { app.SelectTab("auth") d.views.SwitchToPage("Authentication") return } app.SelectTab(d.CurrentPage()) for _, i := range d.Tabs().Info { if i.ID == d.CurrentPage() { d.views.SwitchToPage(i.Title) app.UI.SetFocus(d.getTableMap()[i.Title].table) break } } } // Load loads the dashboard view according to the provided page type. func (d *DashboardView) Load(pageType string, reload ...struct{}) { switch pageType { case "feed": go d.loadFeed(reload != nil) case "playlists": go d.loadPlaylists(reload != nil) case "subscriptions": go d.loadSubscriptions(reload != nil) } d.CurrentPage(pageType) d.View() } // EventHandler checks whether authentication is needed // before showing the dashboard view. func (d *DashboardView) EventHandler() { d.Init() if pg, _ := d.views.GetFrontPage(); d.views.HasFocus() && pg != "Authentication" { d.Load(d.CurrentPage(), struct{}{}) return } go d.checkAuth() } // AuthPage shows the authentication page. func (d *DashboardView) AuthPage() { app.ShowInfo("Authentication required", false) authText := "No authorization token found or token is invalid.\n\n" + "To authenticate, do either of the listed steps:\n\n" + "- Navigate to [::b]" + client.Instance() + "/token_manager[-:-:-] " + "and copy the [::u]SID[-:-:-] (the base64 string on top of a red background)\n\n" + "- Navigate to [::b]" + client.AuthLink() + "[-:-:-] and click 'OK' when prompted for confirmation, " + "then copy the [::u]session token[-:-:-]" + "\n\nPaste the SID or Token in the inputbox below and press Enter." d.message.SetText(authText) d.token.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEscape: d.Keybindings(event) case tcell.KeyEnter: app.UI.SetFocus(d.message) go d.validateToken() } return event }) d.View(struct{}{}) } // ModifyHandler handles the following activities: // - Adding/removing videos to/from a user playlist // - Deleting user playlists // - Subscribing/unsubscribing to/from channels func (d *DashboardView) ModifyHandler(add bool) { d.Init() info, err := app.FocusedTableReference() if err != nil { app.ShowError(err) return } if !client.IsAuthInstance() { app.ShowInfo("Authentication is required", false) return } go func(i inv.SearchData, lock *semaphore.Weighted, focused bool) { if !lock.TryAcquire(1) { app.ShowInfo("Operation in progress for "+info.Type, false) return } defer lock.Release(1) switch info.Type { case "video": d.modifyVideoInPlaylist(i, add, lock) case "playlist": d.modifyPlaylist(i, add, focused) case "channel": d.modifySubscription(i, add, focused) } }(info, d.modifyMap[info.Type], d.views.HasFocus()) } // PlaylistForm displays a form to create/edit a user playlist. func (d *DashboardView) PlaylistForm(edit bool) { var modal *app.Modal var info inv.SearchData mode := "Create" if edit { mode = "Edit" info, _ = app.FocusedTableReference() } form := tview.NewForm() form.SetBackgroundColor(tcell.ColorDefault) form.AddInputField("Name: ", info.Title, 0, nil, nil) form.AddDropDown("Privacy: ", []string{"public", "unlisted", "private"}, -1, nil) if edit { form.AddInputField("Description: ", info.Description, 0, nil, nil) } form.AddButton(mode, func() { go d.playlistFormHandler(form, modal, info, mode, edit) }) form.AddButton("Cancel", func() { modal.Exit(false) }) form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEscape: modal.Exit(false) } return event }) modal = app.NewModal("playlist_editor", mode+" playlist", form, form.GetFormItemCount()+10, 60) modal.Show(false) } // Keybindings defines the keybindings for the dashboard view. func (d *DashboardView) Keybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event, cmd.KeyContextDashboard) { case cmd.KeySwitchTab: tab := d.Tabs() tab.Selected = d.CurrentPage() d.CurrentPage(app.SwitchTab(false, tab)) client.Cancel() app.ShowInfo("", false) d.Load(d.CurrentPage()) case cmd.KeyClose: client.Cancel() CloseView() case cmd.KeyDashboardReload: d.Load(d.CurrentPage(), struct{}{}) } return event } // feedKeybindings defines keybindings for the feed page. func (d *DashboardView) feedKeybindings(event *tcell.EventKey) *tcell.EventKey { d.Keybindings(event) switch cmd.KeyOperation(event, cmd.KeyContextComments) { case cmd.KeyLoadMore: d.loadFeed(false, struct{}{}) case cmd.KeyAdd: d.ModifyHandler(true) case cmd.KeyLink: popup.ShowLink() case cmd.KeyComments: Comments.Show() } return event } // plKeybindings defines keybindings for the playlist page. func (d *DashboardView) plKeybindings(event *tcell.EventKey) *tcell.EventKey { d.Keybindings(event) switch cmd.KeyOperation(event, cmd.KeyContextDashboard) { case cmd.KeyPlaylist: Playlist.EventHandler(event.Modifiers() == tcell.ModAlt, true) case cmd.KeyDashboardCreatePlaylist, cmd.KeyDashboardEditPlaylist: d.PlaylistForm(event.Rune() == 'e') case cmd.KeyRemove: d.ModifyHandler(false) case cmd.KeyLink: popup.ShowLink() } return event } // subKeybindings defines keybindings for the subscription page. func (d *DashboardView) subKeybindings(event *tcell.EventKey) *tcell.EventKey { d.Keybindings(event) switch cmd.KeyOperation(event, cmd.KeyContextComments) { case cmd.KeyChannelVideos: Channel.EventHandler("video", event.Modifiers() == tcell.ModAlt) case cmd.KeyChannelPlaylists: Channel.EventHandler("playlist", event.Modifiers() == tcell.ModAlt) case cmd.KeyRemove: d.ModifyHandler(false) case cmd.KeyComments: Comments.Show() } return event } // checkAuth checks if the user is authenticated // before loading the dashboard. func (d *DashboardView) checkAuth() { if !d.lock.TryAcquire(1) { return } defer d.lock.Release(1) app.ShowInfo("Loading dashboard", true) auth := client.IsAuthInstance() && client.CurrentTokenValid() app.UI.QueueUpdateDraw(func() { if auth { d.Load(d.CurrentPage(), struct{}{}) app.ShowInfo("Dashboard loaded", false) return } d.AuthPage() }) } // playlistFormHandler handles creating/editing the user playlist. func (d *DashboardView) playlistFormHandler( form *tview.Form, modal *app.Modal, info inv.SearchData, mode string, edit bool, ) { var description string title := form.GetFormItem(0).(*tview.InputField).GetText() _, privacy := form.GetFormItem(1).(*tview.DropDown).GetCurrentOption() if title == "" || privacy == "" { app.ShowError(fmt.Errorf("View: Dashboard: Cannot submit empty form data")) return } app.UI.QueueUpdateDraw(func() { modal.Exit(false) }) if !edit { mode = mode[:len(mode)-1] } app.ShowInfo(mode+"ing playlist "+info.Title, true) if edit { description = form.GetFormItem(2).(*tview.InputField).GetText() err := inv.EditPlaylist(info.PlaylistID, title, description, privacy) if err != nil { app.ShowError(err) return } newInfo := info newInfo.Title = title newInfo.Description = description title = "[blue::b]" + tview.Escape(title) app.UI.QueueUpdateDraw(func() { if err := app.ModifyReference(title, true, info, newInfo); err != nil { app.ShowError(err) } }) } else { if err := inv.CreatePlaylist(title, privacy); err != nil { app.ShowError(err) return } d.loadPlaylists(true) } app.ShowInfo(mode+"ed playlist "+info.Title, false) } // modifySubscription adds/removes a channel subscription. func (d *DashboardView) modifySubscription(info inv.SearchData, add, focused bool) { if add && !focused { app.ShowInfo("Subscribing to "+info.Author, true) if err := inv.AddSubscription(info.AuthorID); err != nil { app.ShowError(err) return } app.ShowInfo("Subscribed to "+info.Author, false) return } if !add && !focused { return } app.ShowInfo("Unsubscribing from "+info.Author, true) if err := inv.RemoveSubscription(info.AuthorID); err != nil { app.ShowError(err) return } app.UI.QueueUpdateDraw(func() { if err := app.ModifyReference("", false, info); err != nil { app.ShowError(err) } }) app.ShowInfo("Unsubscribed from "+info.Author, false) } // modifyPlaylist removes a user playlist. func (d *DashboardView) modifyPlaylist(info inv.SearchData, add, focused bool) { if add || !focused { return } app.ShowInfo("Removing playlist "+info.Title, true) if err := inv.RemovePlaylist(info.PlaylistID); err != nil { app.ShowError(err) return } app.UI.QueueUpdateDraw(func() { if err := app.ModifyReference("", false, info); err != nil { app.ShowError(err) } }) app.ShowInfo("Removed playlist "+info.Title, false) } // modifyVideoInPlaylist adds/removes videos in a playlist. func (d *DashboardView) modifyVideoInPlaylist(info inv.SearchData, add bool, lock *semaphore.Weighted) { if !add { d.removeVideo(info) return } var modal *app.Modal app.ShowInfo("Retrieving playlists", true) playlists, err := inv.UserPlaylists() if err != nil { app.ShowError(err) return } if len(playlists) == 0 { app.ShowInfo("No user playlists found", false) return } app.ShowInfo("Retrieved playlists", false) table := tview.NewTable() table.SetBorders(false) table.SetSelectorWrap(true) table.SetSelectable(true, false) table.SetBackgroundColor(tcell.ColorDefault) table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEscape: modal.Exit(false) case tcell.KeyEnter: playlist, _ := app.FocusedTableReference() modal.Exit(false) go d.addVideo(info, playlist, lock) } return event }) for i, p := range playlists { ref := inv.SearchData{ Type: "playlist", Title: p.Title, PlaylistID: p.PlaylistID, Author: p.Author, } table.SetCell(i, 0, tview.NewTableCell("[blue::b]"+tview.Escape(p.Title)). SetExpansion(1). SetReference(ref). SetSelectedStyle(app.UI.SelectedStyle), ) table.SetCell(i, 1, tview.NewTableCell("[pink]"+strconv.FormatInt(p.VideoCount, 10)+" videos"). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } modal = app.NewModal("user_playlists", "Add to playlist", table, 20, 60) app.UI.QueueUpdateDraw(func() { modal.Show(false) }) } // removeVideo removes a video from a user playlist. func (d *DashboardView) removeVideo(info inv.SearchData) { app.ShowInfo("Removing video from "+info.Title, true) if err := inv.RemoveVideoFromPlaylist(info.PlaylistID, info.IndexID); err != nil { app.ShowError(err) return } app.UI.QueueUpdateDraw(func() { if err := app.ModifyReference("", false, info); err != nil { app.ShowError(err) } }) app.ShowInfo("Removed video from "+info.Title, false) } // addVideo adds a video to a user playlist. func (d *DashboardView) addVideo(info, playlist inv.SearchData, lock *semaphore.Weighted) { if !lock.TryAcquire(1) { app.ShowError(fmt.Errorf("View: Dashboard: Cannot add video, operation in progress")) return } defer lock.Release(1) app.ShowInfo("Adding "+info.Title+" to "+playlist.Title, true) err := inv.AddVideoToPlaylist(playlist.PlaylistID, info.VideoID) if err != nil { app.ShowError(err) return } app.ShowInfo("Added "+info.Title+" to "+playlist.Title, false) } // loadFeed loads and renders the user feed. func (d *DashboardView) loadFeed(reload bool, loadMore ...struct{}) { feedView := d.getTableMap()["Feed"] if loadMore != nil { feedView.page++ goto LoadFeed } else { feedView.page = 1 } if !reload && feedView.loaded { return } LoadFeed: app.ShowInfo("Loading feed", true) feed, err := inv.Feed(feedView.page) if err != nil { app.ShowError(err) return } feedView.loaded = true app.UI.QueueUpdateDraw(func() { var skipped int if loadMore == nil { feedView.table.Clear() } pos := -1 _, _, width, _ := app.UI.Pages.GetRect() rows := feedView.table.GetRowCount() for i, video := range feed.Videos { if video.LengthSeconds == 0 { skipped++ continue } if pos < 0 { pos = (rows + i) - skipped } sref := inv.SearchData{ Type: "video", Title: video.Title, VideoID: video.VideoID, AuthorID: video.AuthorID, Author: video.Author, } feedView.table.SetCell((rows+i)-skipped, 0, tview.NewTableCell("[blue::b]"+tview.Escape(video.Title)). SetExpansion(1). SetReference(sref). SetMaxWidth((width / 4)). SetSelectedStyle(app.UI.SelectedStyle), ) feedView.table.SetCell((rows+i)-skipped, 1, tview.NewTableCell("[pink]"+utils.FormatDuration(video.LengthSeconds)). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } feedView.table.SetSelectable(true, false) if pos > 0 { feedView.table.Select(pos, 0) } }) app.ShowInfo("Feed loaded", false) } // loadPlaylists loads and renders the user playlists. func (d *DashboardView) loadPlaylists(reload bool) { plView := d.getTableMap()["Playlists"] if !reload && plView.loaded { return } app.ShowInfo("Loading playlists", true) playlists, err := inv.UserPlaylists() if err != nil { app.ShowError(err) return } plView.loaded = true app.UI.QueueUpdateDraw(func() { _, _, width, _ := app.UI.Pages.GetRect() plView.table.SetSelectable(false, false) for i, playlist := range playlists { sref := inv.SearchData{ Type: "playlist", Title: playlist.Title, PlaylistID: playlist.PlaylistID, AuthorID: playlist.AuthorID, Author: playlist.Author, } plView.table.SetCell(i, 0, tview.NewTableCell("[blue::b]"+tview.Escape(playlist.Title)). SetExpansion(1). SetReference(sref). SetMaxWidth((width / 4)). SetSelectedStyle(app.UI.SelectedStyle), ) plView.table.SetCell(i, 1, tview.NewTableCell("[pink]"+strconv.FormatInt(playlist.VideoCount, 10)+" videos"). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } plView.table.SetSelectable(true, false) }) app.ShowInfo("Playlists loaded", false) } // loadSubscriptions loads and renders the user subscriptions. func (d *DashboardView) loadSubscriptions(reload bool) { subView := d.getTableMap()["Subscriptions"] if !reload && subView.loaded { return } app.ShowInfo("Loading subscriptions", true) subscriptions, err := inv.Subscriptions() if err != nil { app.ShowError(err) return } subView.loaded = true app.UI.QueueUpdateDraw(func() { _, _, width, _ := app.UI.Pages.GetRect() subView.table.SetSelectable(false, false) for i, subscription := range subscriptions { sref := inv.SearchData{ Type: "channel", Author: subscription.Author, AuthorID: subscription.AuthorID, } subView.table.SetCell(i, 0, tview.NewTableCell("[blue::b]"+tview.Escape(subscription.Author)). SetExpansion(1). SetReference(sref). SetMaxWidth((width / 4)). SetSelectedStyle(app.UI.SelectedStyle), ) } subView.table.SetSelectable(true, false) }) app.ShowInfo("Subscriptions loaded", false) } // validateToken validates the provided token // in the authentication page. func (d *DashboardView) validateToken() { app.ShowInfo("Checking token", true) if !client.IsTokenValid(d.token.GetText()) { app.ShowError(fmt.Errorf("View: Dashboard: Token is invalid")) app.UI.QueueUpdateDraw(func() { app.UI.SetFocus(d.token) }) return } client.AddCurrentAuth(d.token.GetText()) d.Load(d.CurrentPage()) } // getTableMap gets a map of tables within the dashboard view. func (d *DashboardView) getTableMap() map[string]*DashboardTable { d.mutex.Lock() defer d.mutex.Unlock() return d.tableMap } // queueWrite executes the given function thread-safely. func (d *DashboardView) queueWrite(write func()) { d.mutex.Lock() defer d.mutex.Unlock() write() } invidtui-0.3.7/ui/view/download.go000066400000000000000000000251161454311651000171060ustar00rootroot00000000000000package view import ( "context" "fmt" "io" "os" "path/filepath" "strconv" "strings" "time" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" "github.com/schollz/progressbar/v3" ) // DownloadsView describes the layout of a downloads view. type DownloadsView struct { init bool modal *app.Modal options, view *tview.Table } // DownloadProgress describes the layout of a progress indicator. type DownloadProgress struct { desc, progress *tview.TableCell bar *progressbar.ProgressBar cancelFunc context.CancelFunc } // DownloadData describes the information for the downloading item. type DownloadData struct { id, title, dtype string format inv.VideoFormat } // Downloads stores the downloads view properties. var Downloads DownloadsView // Name returns the name of the downloads view. func (d *DownloadsView) Name() string { return "Downloads" } // Init initializes the downloads view. func (d *DownloadsView) Init() bool { if d.init { return true } d.options = tview.NewTable() d.options.SetSelectorWrap(true) d.options.SetSelectable(true, false) d.options.SetBackgroundColor(tcell.ColorDefault) d.options.SetInputCapture(d.OptionKeybindings) d.options.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextDownloads, d.options) }) d.view = tview.NewTable() d.view.SetBorder(true) d.view.SetSelectorWrap(true) d.view.SetTitle("Download List") d.view.SetSelectable(true, false) d.view.SetTitleAlign(tview.AlignLeft) d.view.SetBackgroundColor(tcell.ColorDefault) d.view.SetInputCapture(d.Keybindings) d.view.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextDownloads, d.view) }) d.modal = app.NewModal("downloads", "Select Download Option", d.options, 40, 60) d.init = true return true } // Exit closes the downloads view. func (d *DownloadsView) Exit() bool { return true } // Tabs describes the tab layout for the downloads view. func (d *DownloadsView) Tabs() app.Tab { return app.Tab{} } // Primitive returns the primitive for the downloads view. func (d *DownloadsView) Primitive() tview.Primitive { return d.view } // View shows the download view. func (d *DownloadsView) View() { if d.view == nil { return } SetView(&Downloads) } // ShowOptions shows a list of download options for the selected video. func (d *DownloadsView) ShowOptions(data ...inv.SearchData) { var err error var info inv.SearchData if data != nil { info = data[0] goto Options } info, err = app.FocusedTableReference() if err != nil { app.ShowError(err) return } if info.Type != "video" { return } if cmd.GetOptionValue("download-dir") == "" { d.SetDir(info) return } Options: d.Init() go d.LoadOptions(info.VideoID, info.Title) } // SetDir sets the download directory. func (d *DownloadsView) SetDir(info ...inv.SearchData) { app.UI.FileBrowser.Show("Download file to:", func(name string) { if stat, err := os.Stat(name); err != nil || !stat.IsDir() { if err == nil { err = fmt.Errorf("View: Downloads: Selected item is not a directory") } app.ShowError(err) return } cmd.SetOptionValue("download-dir", name) app.UI.QueueUpdateDraw(func() { app.UI.FileBrowser.Hide() if info != nil { d.ShowOptions(info[0]) } }) }, app.FileBrowserOptions{ ShowDirOnly: true, SetDir: cmd.GetOptionValue("download-dir"), }) } // LoadOptions loads the download options for the selected video. func (d *DownloadsView) LoadOptions(id, title string) { app.ShowInfo("Getting download options", true) video, err := inv.Video(id) if err != nil { app.ShowError(err) return } if video.LiveNow { app.ShowError(fmt.Errorf("View: Downloads: Cannot download live video")) return } app.ShowInfo("Showing download options", false) go app.UI.QueueUpdateDraw(func() { d.renderOptions(video) d.modal.Show(false) }) } // TransferVideo starts the download for the selected video. func (d *DownloadsView) TransferVideo(id, itag, filename string) { var progress DownloadProgress app.ShowInfo("Starting download for video "+tview.Escape(filename), false) ctx, cancel := context.WithCancel(context.Background()) defer cancel() res, file, err := inv.DownloadParams(ctx, id, itag, filename) if err != nil { app.ShowError(err) return } defer res.Body.Close() defer file.Close() progress.renderBar(filename, res.ContentLength, cancel, true) defer app.UI.QueueUpdateDraw(func() { progress.remove() }) _, err = io.Copy(io.MultiWriter(file, progress.bar), res.Body) if err != nil { app.ShowError(err) } } // TransferPlaylist starts the download for the selected playlist. func (d *DownloadsView) TransferPlaylist(id, file string, flags int, auth, appendToFile bool) (string, int, error) { var progress DownloadProgress filename := filepath.Base(file) d.Init() app.ShowInfo("Starting download for playlist '"+tview.Escape(filename)+"'", false) ctx, cancel := context.WithCancel(context.Background()) defer cancel() progress.renderBar(filename, 0, cancel, false) defer app.UI.QueueUpdateDraw(func() { progress.remove() }) videos, err := inv.PlaylistVideos(ctx, id, auth, func(stats [3]int64) { if progress.bar.GetMax() <= 0 { progress.bar.ChangeMax64(stats[2]) progress.bar.Reset() } progress.bar.Set64(stats[1]) }) if err != nil { return "", flags, err } return inv.GeneratePlaylist(file, videos, flags, appendToFile) } // OptionKeybindings describes the keybindings for the download options popup. func (d *DownloadsView) OptionKeybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event, cmd.KeyContextDownloads) { case cmd.KeyDownloadChangeDir: d.SetDir() case cmd.KeyDownloadOptionSelect: row, _ := d.options.GetSelection() cell := d.options.GetCell(row, 0) if data, ok := cell.GetReference().(DownloadData); ok { filename := data.title + "." + data.format.Container go d.TransferVideo(data.id, data.format.Itag, filename) } fallthrough case cmd.KeyClose: d.modal.Exit(false) } return event } // Keybindings describes the keybindings for the downloads view. func (d *DownloadsView) Keybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event, cmd.KeyContextDownloads) { case cmd.KeyDownloadCancel: row, _ := Downloads.view.GetSelection() cell := Downloads.view.GetCell(row, 0) if progress, ok := cell.GetReference().(*DownloadProgress); ok { progress.cancelFunc() } case cmd.KeyClose: CloseView() } return event } // renderOptions render the download options popup. func (d *DownloadsView) renderOptions(video inv.VideoData) { var skipped, width int d.options.Clear() for i, formatData := range [][]inv.VideoFormat{ video.FormatStreams, video.AdaptiveFormats, } { rows := d.options.GetRowCount() for row, format := range formatData { var err error var minfo, size string var optionInfo []string if i != 0 { minfo = " only" } else { minfo = " + audio" clen := utils.GetDataFromURL(format.URL).Get("clen") format.ContentLength, err = strconv.ParseInt(clen, 10, 64) if err != nil { format.ContentLength = 0 } } mtype := strings.Split(strings.Split(format.Type, ";")[0], "/") if (mtype[0] == "audio" && (format.Container == "" || format.Encoding == "")) || (mtype[0] == "video" && format.FPS == 0) { skipped++ continue } if format.ContentLength == 0 { size = "-" } else { size = strconv.FormatFloat(float64(format.ContentLength)/1024/1024, 'f', 2, 64) } optionInfo = []string{ "[red::b]" + mtype[0] + minfo + "[-:-:-]", "[blue::b]" + size + " MB[-:-:-]", "[purple::b]" + format.Container + "/" + format.Encoding + "[-:-:-]", } if mtype[0] != "audio" { optionInfo = append(optionInfo, []string{ "[green::b]" + format.Resolution + "[-:-:-]", "[yellow::b]" + strconv.Itoa(format.FPS) + "fps[-:-:-]", }...) } else { optionInfo = append(optionInfo, []string{ "[lightpink::b]" + strconv.Itoa(format.AudioSampleRate) + "kHz[-:-:-]", "[grey::b]" + strconv.Itoa(format.AudioChannels) + "ch[-:-:-]", }...) } data := DownloadData{ id: video.VideoID, title: video.Title, dtype: "video", format: format, } option := strings.Join(optionInfo, ", ") optionLength := tview.TaggedStringWidth(option) + 6 if optionLength > width { width = optionLength } d.options.SetCell((rows+row)-skipped, 0, tview.NewTableCell(option). SetExpansion(1). SetReference(data). SetSelectedStyle(app.UI.ColumnStyle), ) } } d.modal.Width = width if d.options.GetRowCount() < d.modal.Height { d.modal.Height = d.options.GetRowCount() + 4 } } // remove removes the currently downloading item from the downloads view. func (p *DownloadProgress) remove() { if Downloads.view == nil { return } for row := 0; row < Downloads.view.GetRowCount(); row++ { cell := Downloads.view.GetCell(row, 0) progress, ok := cell.GetReference().(*DownloadProgress) if !ok { continue } if p == progress { Downloads.view.RemoveRow(row) Downloads.view.RemoveRow(row - 1) break } } if Downloads.view.HasFocus() && Downloads.view.GetRowCount() == 0 { Downloads.view.InputHandler()(tcell.NewEventKey(tcell.KeyEscape, ' ', tcell.ModNone), nil) } } // renderBar renders the progress bar within the downloads view. func (p *DownloadProgress) renderBar(filename string, clen int64, cancel func(), video bool) { options := []progressbar.Option{ progressbar.OptionSpinnerType(34), progressbar.OptionSetWriter(p), progressbar.OptionSetRenderBlankState(true), progressbar.OptionSetElapsedTime(false), progressbar.OptionShowCount(), progressbar.OptionSetPredictTime(false), progressbar.OptionThrottle(200 * time.Millisecond), } if video { options = append(options, progressbar.OptionShowBytes(true)) } p.desc = tview.NewTableCell("[::b]" + tview.Escape(filename)). SetExpansion(1). SetSelectable(true). SetAlign(tview.AlignLeft) p.progress = tview.NewTableCell(""). SetExpansion(1). SetSelectable(false). SetAlign(tview.AlignRight) p.bar = progressbar.NewOptions64(clen, options...) p.cancelFunc = cancel app.UI.QueueUpdateDraw(func() { rows := Downloads.view.GetRowCount() Downloads.view.SetCell(rows+1, 0, p.desc.SetReference(p)) Downloads.view.SetCell(rows+1, 1, p.progress) Downloads.view.Select(rows+1, 0) }) } // Write generates the progress bar. func (p *DownloadProgress) Write(b []byte) (int, error) { app.UI.QueueUpdateDraw(func() { p.progress.SetText(string(b)) }) return 0, nil } invidtui-0.3.7/ui/view/info_view.go000066400000000000000000000031201454311651000172530ustar00rootroot00000000000000package view import ( "strings" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // InfoView describes the layout for a playlist/channel page. // It displays a title, description and the entries. type InfoView struct { flex *tview.Flex title, description *tview.TextView primitive tview.Primitive } // Init initializes the info view. func (i *InfoView) Init(primitive tview.Primitive) { i.flex = tview.NewFlex(). SetDirection(tview.FlexRow) i.flex.SetBackgroundColor(tcell.ColorDefault) i.title = tview.NewTextView() i.title.SetDynamicColors(true) i.title.SetTextAlign(tview.AlignCenter) i.title.SetBackgroundColor(tcell.ColorDefault) i.description = tview.NewTextView() i.description.SetDynamicColors(true) i.description.SetTextAlign(tview.AlignCenter) i.description.SetBackgroundColor(tcell.ColorDefault) i.primitive = primitive } // Set sets the title and description of the info view. func (i *InfoView) Set(title, description string) { var descSize int _, _, pageWidth, _ := app.UI.Pages.GetRect() descText := strings.ReplaceAll(description, "\n", " ") descLength := len(descText) if descLength > 0 { descSize = 2 if descLength >= pageWidth { descSize++ } } i.flex.Clear() i.flex.AddItem(i.title, 1, 0, false) i.flex.AddItem(app.HorizontalLine(), 1, 0, false) if descLength > 0 { i.flex.AddItem(i.description, descSize, 0, false) i.flex.AddItem(app.HorizontalLine(), 1, 0, false) } i.flex.AddItem(i.primitive, 0, 10, true) i.title.SetText("[::bu]" + title) i.description.SetText(descText) } invidtui-0.3.7/ui/view/playlist.go000066400000000000000000000140131454311651000171320ustar00rootroot00000000000000package view import ( "fmt" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/ui/popup" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" "golang.org/x/sync/semaphore" ) // PlaylistView describes the layout of a playlist view. type PlaylistView struct { ID string init, auth, removed bool page int idmap map[string]struct{} table *tview.Table infoView InfoView lock *semaphore.Weighted } // Playlist stores the playlist view properties. var Playlist PlaylistView // Name returns the name of the playlist view. func (p *PlaylistView) Name() string { return "Playlist" } // Init initializes the playlist view. func (p *PlaylistView) Init() bool { if p.init { return true } p.table = tview.NewTable() p.table.SetSelectorWrap(true) p.table.SetInputCapture(p.Keybindings) p.table.SetBackgroundColor(tcell.ColorDefault) p.table.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextPlaylist, p.table) }) p.infoView.Init(p.table) p.idmap = make(map[string]struct{}) p.lock = semaphore.NewWeighted(1) p.init = true return true } // Exit closes the playlist view. func (p *PlaylistView) Exit() bool { if p.removed { if v := PreviousView(); v != nil && v.Name() == Dashboard.Name() { Dashboard.Load(Dashboard.CurrentPage(), struct{}{}) } } return true } // Tabs returns the tab layout for the playlist view. func (p *PlaylistView) Tabs() app.Tab { return app.Tab{ Title: "Playlist", Info: []app.TabInfo{ {ID: "video", Title: "Videos"}, }, Selected: "video", } } // Primitive returns the primitive for the playlist view. func (p *PlaylistView) Primitive() tview.Primitive { return p.infoView.flex } // View shows the playlist view. func (p *PlaylistView) View() { if p.infoView.flex == nil || p.infoView.flex.GetItemCount() == 0 { return } SetView(&Playlist) } // EventHandler shows the playlist view for the currently selected playlist. func (p *PlaylistView) EventHandler(justView, auth bool, loadMore ...struct{}) { if justView { p.View() return } p.Init() p.auth = auth p.removed = false info, err := app.FocusedTableReference() if err != nil { app.ShowError(err) return } if info.Type != "playlist" { app.ShowError(fmt.Errorf("View: Playlist: Cannot load from %s type", info.Type)) return } go p.Load(info.PlaylistID, loadMore...) } // Load loads the playlist. func (p *PlaylistView) Load(id string, loadMore ...struct{}) { if !p.lock.TryAcquire(1) { app.ShowError(fmt.Errorf("View: Playlist: Still loading data")) return } defer p.lock.Release(1) if loadMore != nil { p.page++ } else { p.page = 1 p.ID = id p.idmap = make(map[string]struct{}) } app.ShowInfo("Loading Playlist results", true) result, err := inv.Playlist(p.ID, p.auth, p.page) if err != nil { app.ShowError(err) return } if len(result.Videos) == 0 { app.ShowError(fmt.Errorf("View: Playlist: No more results")) return } app.UI.QueueUpdateDraw(func() { if loadMore == nil { p.infoView.Set(result.Title, result.Description) p.View() p.table.Clear() } p.renderPlaylist(result, p.ID) }) app.ShowInfo("Playlist loaded", false) } // Save downloads and saves the playlist to a file. func (p *PlaylistView) Save(id string, auth bool) { app.UI.FileBrowser.Show("Save playlist to:", func(file string) { app.UI.FileBrowser.SaveFile(file, func(flags int, appendToFile bool) (string, int, error) { return Downloads.TransferPlaylist(id, file, flags, auth, appendToFile) }) }) } // Keybindings describes the keybindings for the playlist view. func (p *PlaylistView) Keybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event, cmd.KeyContextCommon, cmd.KeyContextComments, cmd.KeyContextPlaylist) { case cmd.KeyLoadMore: go p.Load(p.ID, struct{}{}) case cmd.KeyPlaylistSave: go Playlist.Save(p.ID, p.auth) case cmd.KeyClose: CloseView() case cmd.KeyAdd: if !Dashboard.IsFocused() { Dashboard.ModifyHandler(true) } case cmd.KeyRemove: if v := PreviousView(); v != nil && v.Name() == Dashboard.Name() { Dashboard.ModifyHandler(false) } case cmd.KeyLink: popup.ShowLink() case cmd.KeyComments: Comments.Show() } return event } // renderPlaylist renders the playlist view. func (p *PlaylistView) renderPlaylist(result inv.PlaylistData, id string) { var skipped int pos := -1 rows := p.table.GetRowCount() _, _, pageWidth, _ := app.UI.Pages.GetRect() previousView := PreviousView() prevDashboard := previousView != nil && previousView.Name() == Dashboard.Name() p.table.SetSelectable(false, false) for i, v := range result.Videos { select { case <-client.Ctx().Done(): return default: } if pos < 0 { pos = (rows + i) - skipped } if !prevDashboard { _, ok := p.idmap[v.VideoID] if ok { skipped++ continue } p.idmap[v.VideoID] = struct{}{} } sref := inv.SearchData{ Type: "video", Title: v.Title, VideoID: v.VideoID, AuthorID: v.AuthorID, IndexID: v.IndexID, PlaylistID: id, Author: result.Author, } p.table.SetCell((rows+i)-skipped, 0, tview.NewTableCell("[blue::b]"+tview.Escape(v.Title)). SetExpansion(1). SetReference(sref). SetMaxWidth((pageWidth / 4)). SetSelectedStyle(app.UI.SelectedStyle), ) p.table.SetCell((rows+i)-skipped, 1, tview.NewTableCell("[pink]"+utils.FormatDuration(v.LengthSeconds)). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } if skipped == len(result.Videos) { app.ShowInfo("No more results", false) p.table.SetSelectable(true, false) return } app.ShowInfo("Playlist entries loaded", false) if pos >= 0 { p.table.Select(pos, 0) if pos == 0 { p.table.ScrollToBeginning() } else { p.table.ScrollToEnd() } } p.table.SetSelectable(true, false) if pg, _ := app.UI.Pages.GetFrontPage(); pg == "ui" { app.UI.SetFocus(p.table) } } invidtui-0.3.7/ui/view/search.go000066400000000000000000000347011454311651000165440ustar00rootroot00000000000000package view import ( "fmt" "strconv" "strings" "github.com/darkhz/invidtui/client" "github.com/darkhz/invidtui/cmd" inv "github.com/darkhz/invidtui/invidious" "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/invidtui/ui/popup" "github.com/darkhz/invidtui/utils" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" "golang.org/x/sync/semaphore" ) // SearchView describes the layout for a search view. type SearchView struct { init bool page, pos int currentType, savedText, file, tab string entries []string table *tview.Table suggestBox *app.Modal suggestText string parametersBox *app.Modal parametersForm *tview.Form parameters map[string]string lock *semaphore.Weighted } var ( // Search stores the search view properties Search SearchView formParams = map[string]map[string][]string{ "Date:": {"date": []string{ "", "hour", "week", "year", "month", "today", }}, "Sort By:": {"sort_by": []string{ "", "rating", "relevance", "view_count", "upload_date", }}, "Duration:": {"duration": []string{ "", "long", "short", }}, "Features:": {"features": []string{ "4k", "hd", "3d", "360", "hdr", "live", "location", "purchased", "subtitles", "creative_commons", }}, "Region:": {"region": []string{}}, } ) // Name returns the name of the search view. func (s *SearchView) Name() string { return "Search" } // Init initializes the search view. func (s *SearchView) Init() bool { if s.init { return true } s.currentType = "video" s.tab = s.currentType s.table = tview.NewTable() s.table.SetBorder(false) s.table.SetSelectorWrap(true) s.table.SetInputCapture(s.Keybindings) s.table.SetBackgroundColor(tcell.ColorDefault) s.table.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextSearch, s.table) }) s.suggestBox = app.NewModal("suggestion", "Suggestions", nil, 0, 0) s.suggestBox.Table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEscape: s.suggestBox.Exit(true) } return event }) s.suggestBox.Table.SetSelectionChangedFunc(func(row, column int) { text := s.suggestBox.Table.GetCell(row, column).Text app.UI.Status.SetText(text) }) if s.parametersForm == nil { s.parametersForm = tview.NewForm() } s.parametersBox = app.NewModal("parameters", "Set Search Parameters", s.parametersForm, 40, 60) s.parameters = make(map[string]string) s.lock = semaphore.NewWeighted(1) s.setupHistory() s.init = true return true } // Exit closes the search view. func (s *SearchView) Exit() bool { return true } // Tabs returns the tab layout for the search view. func (s *SearchView) Tabs() app.Tab { return app.Tab{ Title: "Search", Info: []app.TabInfo{ {ID: "video", Title: "Videos"}, {ID: "playlist", Title: "Playlists"}, {ID: "channel", Title: "Channels"}, }, Selected: s.currentType, } } // Primitive returns the primitive for the search view. func (s *SearchView) Primitive() tview.Primitive { return s.table } // Start shows the search view and fetches results for // the search query. func (s *SearchView) Start(text string) { if text == "" { if !s.lock.TryAcquire(1) { app.ShowInfo("Still loading Search results", false) return } defer s.lock.Release(1) text = s.savedText goto StartSearch } else { client.Cancel() s.page = 0 s.savedText = text } s.addToHistory(text) app.UI.QueueUpdateDraw(func() { s.table.Clear() s.table.SetSelectable(false, false) s.suggestBox.Exit(false) s.parametersBox.Exit(false) app.UI.Status.SwitchToPage("messages") app.SetPrimaryFocus() }) StartSearch: app.ShowInfo("Fetching results", true) results, page, err := inv.Search(s.currentType, text, s.parameters, s.page) if err != nil { app.ShowError(err) return } if results == nil { app.ShowError(fmt.Errorf("View: Search: No more results")) return } s.page = page app.UI.QueueUpdateDraw(func() { SetView(&Search) s.renderResults(results) }) app.ShowInfo("Results fetched", false) } // Query displays a prompt and search for the provided query. func (s *SearchView) Query(switchMode ...struct{}) { s.Init() app.UI.Status.SetFocusFunc(func() { app.SetContextMenu(cmd.KeyContextSearch, app.UI.Status.InputField) }) label := "[::b]Search (" + s.tab + "):" app.UI.Status.SetInput(label, 0, switchMode == nil, Search.Start, Search.inputFunc) } // Suggestions shows search suggestions. func (s *SearchView) Suggestions(text string) { if text == s.suggestText && s.suggestBox.Open { return } s.suggestText = text s.suggestBox.Exit(true) s.suggestBox.Table.Clear() suggestions, err := inv.SearchSuggestions(text) if err != nil { return } app.UI.QueueUpdateDraw(func() { defer app.UI.SetFocus(app.UI.Status.InputField) totalSuggestions := len(suggestions.Suggestions) if totalSuggestions == 0 { s.suggestBox.Exit(true) return } s.suggestBox.Height = totalSuggestions + 1 for row, suggest := range suggestions.Suggestions { s.suggestBox.Table.SetCell(row, 0, tview.NewTableCell(suggest). SetSelectedStyle(app.UI.ColumnStyle), ) } s.suggestBox.Table.Select(0, 0) s.suggestBox.Show(true) }) } // Parameters displays a popup to modify the search parameters. func (s *SearchView) Parameters() { if !s.lock.TryAcquire(1) { app.ShowInfo("Cannot modify Search parameters", false) return } defer s.lock.Release(1) s.parametersForm = s.getParametersForm() s.parametersBox.Flex.RemoveItemIndex(2) s.parametersBox.Flex.AddItem(s.parametersForm, 0, 1, true) app.UI.QueueUpdateDraw(func() { s.parametersBox.Show(true) }) } // ParseQuery parses the 'search-video', 'search-playlist' // and 'search-channel' command-line parameters. func (s *SearchView) ParseQuery() { s.Init() stype, query, err := cmd.GetQueryParams("search") if err != nil { return } s.currentType = stype s.addToHistory(query) go Search.Start(query) } // Keybindings describes the keybindings for the search view. func (s *SearchView) Keybindings(event *tcell.EventKey) *tcell.EventKey { switch cmd.KeyOperation(event, cmd.KeyContextSearch, cmd.KeyContextComments) { case cmd.KeySearchStart: go s.Start("") app.UI.Status.SetFocusFunc() case cmd.KeyClose: CloseView() case cmd.KeyQuery: s.Query() case cmd.KeyPlaylist: Playlist.EventHandler(event.Modifiers() == tcell.ModAlt, false) case cmd.KeyChannelVideos: Channel.EventHandler("video", event.Modifiers() == tcell.ModAlt) case cmd.KeyChannelPlaylists: Channel.EventHandler("playlist", event.Modifiers() == tcell.ModAlt) case cmd.KeyComments: Comments.Show() case cmd.KeyAdd: Dashboard.ModifyHandler(true) case cmd.KeyLink: popup.ShowLink() } return event } // inputFunc describes the keybindings for the search input box. func (s *SearchView) inputFunc(e *tcell.EventKey) *tcell.EventKey { operation := cmd.KeyOperation(e, cmd.KeyContextSearch) switch operation { case cmd.KeySearchStart: s.currentType = s.tab text := app.UI.Status.GetText() if text != "" { go s.Start(text) app.UI.Status.SetFocusFunc() } case cmd.KeyClose: if s.suggestBox.Open { s.suggestBox.Exit(false) goto Event } s.historyReset() s.tab = s.currentType app.SelectTab(s.currentType) app.UI.Status.SetFocusFunc() app.UI.Status.SwitchToPage("messages") app.SetPrimaryFocus() case cmd.KeySearchSuggestions: go s.Suggestions(app.UI.Status.GetText()) case cmd.KeySearchSwitchMode: tab := s.Tabs() tab.Selected = s.tab s.tab = app.SwitchTab(false, tab) s.Query(struct{}{}) case cmd.KeySearchParameters: go s.Parameters() case cmd.KeySearchSuggestionReverse, cmd.KeySearchSuggestionForward: s.suggestionHandler(operation) case cmd.KeySearchHistoryReverse, cmd.KeySearchHistoryForward: if t := s.historyEntry(operation); t != "" { app.UI.Status.SetText(t) } default: return e } Event: return nil } // setupHistory reads the history file and loads the search history. func (s *SearchView) setupHistory() { s.entries = cmd.Settings.SearchHistory s.pos = len(s.entries) } // addToHistory adds text to the history entries buffer. func (s *SearchView) addToHistory(text string) { if text == "" { return } if len(s.entries) == 0 { s.entries = append(s.entries, text) } else if text != s.entries[len(s.entries)-1] { s.entries = append(s.entries, text) } s.pos = len(s.entries) cmd.Settings.SearchHistory = s.entries } // historyEntry returns the search history entry. func (s *SearchView) historyEntry(key cmd.Key) string { switch key { case cmd.KeySearchHistoryReverse: if s.pos-1 < 0 || s.pos-1 >= len(s.entries) { var entry string if s.entries != nil { entry = s.entries[0] } return entry } s.pos-- case cmd.KeySearchHistoryForward: if s.pos+1 >= len(s.entries) { var entry string if s.entries != nil { entry = s.entries[len(s.entries)-1] } return entry } s.pos++ } return s.entries[s.pos] } // historyReset resets the position in the s.entries buffer. func (s *SearchView) historyReset() { s.pos = len(s.entries) } // suggestionHandler handles suggestion popup key events. func (s *SearchView) suggestionHandler(key cmd.Key) { var eventKey tcell.Key switch key { case cmd.KeySearchSuggestionReverse: eventKey = tcell.KeyUp case cmd.KeySearchSuggestionForward: eventKey = tcell.KeyDown } s.suggestBox.Table.InputHandler()(tcell.NewEventKey(eventKey, ' ', tcell.ModNone), nil) } // getParametersForm renders and returns a form to // modify the search parameters. // //gocyclo:ignore func (s *SearchView) getParametersForm() *tview.Form { var form *tview.Form var savedFeatures []string if f, ok := s.parameters["features"]; ok { savedFeatures = strings.Split(f, ",") } if s.parametersForm.GetFormItemCount() > 0 { form = s.parametersForm.Clear(false) goto SetContent } form = tview.NewForm() form.SetItemPadding(2) form.SetHorizontal(true) form.SetBackgroundColor(tcell.ColorDefault) form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEscape: s.parametersBox.Exit(true) } switch event.Rune() { case 'e': if event.Modifiers() == tcell.ModAlt { s.setParameters() } } return event }) form.AddButton("Set", s.setParameters) form.AddButton("Cancel", func() { s.parametersBox.Exit(true) }) SetContent: for label, value := range formParams { var options []string var savedOption string for sp, opts := range value { savedOption = s.parameters[sp] options = opts } switch label { case "Region:": form.AddInputField(label, savedOption, 2, nil, nil) continue case "Features:": for _, o := range options { var checked bool for _, f := range savedFeatures { if f == o { checked = true break } } defer form.AddCheckbox(o, checked, nil) } default: selected := -1 for i, o := range options { if savedOption == "" { break } if o == savedOption { selected = i } } form.AddDropDown(label, options, selected, nil) } } return form } // setParameters sets the search parameters. func (s *SearchView) setParameters() { var features []string for i := 0; i < s.parametersForm.GetFormItemCount(); i++ { var curropt string item := s.parametersForm.GetFormItem(i) label := item.GetLabel() options := formParams[label] if list, ok := item.(*tview.DropDown); ok { _, curropt = list.GetCurrentOption() } else if input, ok := item.(*tview.InputField); ok { curropt = input.GetText() } else if chkbox, ok := item.(*tview.Checkbox); ok { if chkbox.IsChecked() { features = append(features, label) } continue } for p := range options { s.parameters[p] = curropt } } s.parameters["features"] = strings.Join(features, ",") s.parametersBox.Exit(true) s.parametersForm.Clear(true) } // renderResults renders the search view. func (s *SearchView) renderResults(results []inv.SearchData) { var skipped int pos := -1 rows := s.table.GetRowCount() _, _, width, _ := app.UI.Pages.GetRect() for i, result := range results { var author, lentext string select { case <-client.Ctx().Done(): s.table.Clear() return default: } if result.Type == "category" { skipped++ continue } if pos < 0 { pos = (rows + i) - skipped } author = result.Author if result.Title == "" { result.Title = result.Author author = "" } if result.LiveNow { lentext = "Live" } else { lentext = utils.FormatDuration(result.LengthSeconds) } actualRow := (rows + i) - skipped s.table.SetCell(actualRow, 0, tview.NewTableCell("[blue::b]"+tview.Escape(result.Title)). SetExpansion(1). SetReference(result). SetMaxWidth((width / 4)). SetSelectedStyle(app.UI.SelectedStyle), ) s.table.SetCell(actualRow, 1, tview.NewTableCell(" "). SetSelectable(false). SetAlign(tview.AlignRight), ) s.table.SetCell(actualRow, 2, tview.NewTableCell("[purple::b]"+tview.Escape(author)). SetSelectable(true). SetMaxWidth((width / 4)). SetAlign(tview.AlignLeft). SetSelectedStyle(app.UI.ColumnStyle), ) s.table.SetCell(actualRow, 3, tview.NewTableCell(" "). SetSelectable(false). SetAlign(tview.AlignRight), ) if result.Type == "playlist" || result.Type == "channel" { s.table.SetCell(actualRow, 4, tview.NewTableCell("[pink]"+strconv.FormatInt(result.VideoCount, 10)+" videos"). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) if result.Type == "playlist" { continue } } else { s.table.SetCell(actualRow, 4, tview.NewTableCell("[pink]"+lentext). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } s.table.SetCell(actualRow, 5, tview.NewTableCell(" "). SetSelectable(false). SetAlign(tview.AlignRight), ) if result.Type == "channel" { s.table.SetCell(actualRow, 6, tview.NewTableCell("[pink]"+utils.FormatNumber(result.SubCount)+" subs"). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } else { s.table.SetCell(actualRow, 6, tview.NewTableCell("[pink]"+utils.FormatPublished(result.PublishedText)). SetSelectable(true). SetAlign(tview.AlignRight). SetSelectedStyle(app.UI.ColumnStyle), ) } } s.table.Select(pos, 0) s.table.ScrollToEnd() s.table.SetSelectable(true, false) if Banner.shown && len(results) > 0 { app.UI.Pages.SwitchToPage(Search.Name()) } } invidtui-0.3.7/ui/view/view.go000066400000000000000000000023451454311651000162500ustar00rootroot00000000000000package view import ( "github.com/darkhz/invidtui/ui/app" "github.com/darkhz/tview" "github.com/gdamore/tcell/v2" ) // View describes a view. type View interface { Name() string Tabs() app.Tab Init() bool Exit() bool Keybindings(event *tcell.EventKey) *tcell.EventKey Primitive() tview.Primitive } var views []View // SetView sets the current view. func SetView(viewIface View, noappend ...struct{}) { if !viewIface.Init() { return } app.SetTab(viewIface.Tabs()) app.UI.Pages.AddAndSwitchToPage(viewIface.Name(), viewIface.Primitive(), true) app.SetPrimaryFocus() for _, iface := range views { if iface == viewIface && noappend == nil { return } } if noappend != nil { return } views = append(views, viewIface) } // CloseView closes the current view. func CloseView() { vlen := len(views) if !views[vlen-1].Exit() { return } if vlen > 1 { vlen-- views = views[:vlen] } SetView(views[vlen-1], struct{}{}) app.SetPrimaryFocus() } // PreviousView returns the view before the one currently displayed. func PreviousView() View { if len(views) < 2 { return nil } return views[len(views)-2] } // GetCurrentView returns the current view. func GetCurrentView() View { return views[len(views)-1] } invidtui-0.3.7/utils/000077500000000000000000000000001454311651000145145ustar00rootroot00000000000000invidtui-0.3.7/utils/utils.go000066400000000000000000000142361454311651000162110ustar00rootroot00000000000000package utils import ( "fmt" "net/url" "path/filepath" "sort" "strconv" "strings" "time" "github.com/darkhz/invidtui/resolver" urlverify "github.com/davidmytton/url-verifier" ) // FormatDuration takes a duration as seconds and returns a hh:mm:ss string. func FormatDuration(duration int64) string { var durationtext string input, err := time.ParseDuration(strconv.FormatInt(duration, 10) + "s") if err != nil { return "00:00" } d := input.Round(time.Second) h := d / time.Hour d -= h * time.Hour m := d / time.Minute d -= m * time.Minute s := d / time.Second if h > 0 { if h < 10 { durationtext += "0" } durationtext += strconv.Itoa(int(h)) durationtext += ":" } if m > 0 { if m < 10 { durationtext += "0" } durationtext += strconv.Itoa(int(m)) } else { durationtext += "00" } durationtext += ":" if s < 10 { durationtext += "0" } durationtext += strconv.Itoa(int(s)) return durationtext } // FormatPublished takes a duration in the format: "1 day ago", // and returns it in the format: "1d". func FormatPublished(published string) string { ptext := strings.Split(published, " ") if len(ptext) > 1 { return ptext[0] + string(ptext[1][0]) } return ptext[0] } // FormatNumber takes a number and represents it in the // billions(B), millions(M), or thousands(K) format, with // one decimal place. If there is a zero after the decimal, // it is removed. func FormatNumber(num int) string { for i, n := range []int{ 1000000000, 1000000, 1000, } { if num >= n { str := fmt.Sprintf("%.1f%c", float64(num)/float64(n), "BMK"[i]) split := strings.Split(str, ".") if strings.Contains(split[1], "0") { str = split[0] } return str } } return strconv.Itoa(num) } // ConvertDurationToSeconds converts a "hh:mm:ss" string to seconds. func ConvertDurationToSeconds(duration string) int64 { if duration == "" { return 0 } dursplit := strings.Split(duration, ":") length := len(dursplit) switch { case length <= 1: return 0 case length == 2: dursplit = append([]string{"00"}, dursplit...) } for i, v := range []string{"h", "m", "s"} { dursplit[i] = dursplit[i] + v } d, _ := time.ParseDuration(strings.Join(dursplit, "")) return int64(d.Seconds()) } // SplitLines splits a given string into separate lines. func SplitLines(line string) []string { var currPos int var lines []string var joinedString string split := strings.Fields(line) for i, w := range split { joinedString += w + " " if len(joinedString) >= 60 { lines = append(lines, joinedString) joinedString = "" currPos = i } } if lines == nil || currPos < len(split) { lines = append(lines, joinedString) } return lines } // SanitizeCookie sanitizes and returns the provided cookie. // This is used to avoid the logging present in the net/http package. // https://cs.opensource.google/go/go/+/refs/tags/go1.20.5:src/net/http/cookie.go;l=428 func SanitizeCookie(cookie string) string { valid := func(b byte) bool { return 0x20 <= b && b < 0x7f && b != '"' && b != ';' && b != '\\' } ok := true for i := 0; i < len(cookie); i++ { if valid(cookie[i]) { continue } ok = false break } if ok { return cookie } buf := make([]byte, 0, len(cookie)) for i := 0; i < len(cookie); i++ { if b := cookie[i]; valid(b) { buf = append(buf, b) } } return string(buf) } // Deduplicate removes duplicate values from the slice. func Deduplicate(values []string) []string { encountered := make(map[string]int, len(values)) for v := range values { encountered[values[v]] = v } i := 0 keys := make([]int, len(encountered)) for _, pos := range encountered { keys[i] = pos i++ } sort.Ints(keys) dedup := make([]string, len(keys)) for key, pos := range keys { dedup[key] = values[pos] } return dedup } // DecodeSessionData decodes session data from a playlist item. func DecodeSessionData(data string, apply func(prop, value string)) bool { values := strings.Split(data, ",") if len(values) == 0 { return false } for _, value := range values { prop := strings.Split(value, "=") if len(prop) != 2 { continue } apply(prop[0], prop[1]) } return true } // TrimPath cleans and returns a directory path. func TrimPath(testPath string, cdBack bool) string { testPath = filepath.Clean(testPath) if cdBack { testPath = filepath.Dir(testPath) } return filepath.FromSlash(testPath) } // IsValidURL checks if a URL is valid. func IsValidURL(uri string) (*url.URL, error) { v, err := urlverify.NewVerifier().Verify(uri) if err != nil { return nil, err } if !v.IsURL { return nil, fmt.Errorf("invalid URL") } return url.Parse(uri) } // IsValidJSON checks if the text is valid JSON. func IsValidJSON(text string) bool { var msg []byte return resolver.DecodeJSONBytes([]byte(text), &msg) == nil } // GetDataFromURL parses specific url fields and returns their values. func GetDataFromURL(uri string) url.Values { u, err := IsValidURL(uri) if err != nil { return nil } return u.Query() } // GetVPIDFromURL gets the video/playlist ID from a URL. func GetVPIDFromURL(uri string) (string, string, error) { mediaURL := uri if !strings.HasPrefix(uri, "https://") { mediaURL = "https://" + uri } u, err := IsValidURL(mediaURL) if err != nil { return "", "", err } if strings.Contains(uri, "youtu.be") { return strings.TrimLeft(u.Path, "/"), "video", nil } else if strings.Contains(uri, "watch?v=") { return u.Query().Get("v"), "video", nil } else if strings.Contains(uri, "playlist?list=") { return u.Query().Get("list"), "playlist", nil } if strings.Contains(uri, "/channel") || (strings.HasPrefix(uri, "UC") && len(uri) >= 24) { return "", "", fmt.Errorf("the URL or ID is a channel") } if strings.HasPrefix(uri, "PL") && len(uri) >= 34 { return uri, "playlist", nil } return uri, "video", nil } // GetHostname gets the hostname of the given URL. func GetHostname(hostURL string) string { uri, _ := url.Parse(hostURL) hostname := uri.Hostname() if hostname == "" { return hostURL } return hostname } // GetUnixTimeAfter returns the Unix time after the // given number of years. func GetUnixTimeAfter(years int) int64 { return time.Now().AddDate(years, 0, 0).Unix() }