package ui import ( "fmt" "regexp" "strings" "gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb" ) // Playback is what an app's media session says it is doing. // // The screen is not a reliable place to read this. Spotify's play/pause button // keeps the accessibility label "Play" while music is playing, so a recipe that // asserted on the label failed while the phone was busy playing the track. The // media session is the authoritative answer, and it carries the track title. type Playback struct { Package string State string // PLAYING, PAUSED, STOPPED, NONE, ... Title string // the metadata description, usually "title, artist, album" } // Playing reports whether anything is being played. func (p *Playback) Playing() bool { return p != nil && strings.EqualFold(p.State, "PLAYING") } // String is the one-line form used in log lines and error messages. func (p *Playback) String() string { if p == nil { return "no media session" } if p.Title == "" { return fmt.Sprintf("state=%s", p.State) } return fmt.Sprintf("state=%s, playing %q", p.State, p.Title) } var ( playbackStateRe = regexp.MustCompile(`state=PlaybackState \{state=([A-Z]+)`) metadataRe = regexp.MustCompile(`metadata:.*description=(.*)$`) ) // PlaybackState reads the media session of one package. // // It returns an error only when the dump cannot be read; an app that is simply // not playing comes back with a state of PAUSED or NONE. func PlaybackState(r *adb.Runner, pkg string) (*Playback, error) { if pkg == "" { return nil, fmt.Errorf("playback check needs a package name") } out, err := r.Shell("dumpsys media_session") if err != nil { return nil, fmt.Errorf("read media sessions: %w", err) } return parsePlayback(out, pkg), nil } // parsePlayback finds the block for pkg and reads its state and metadata. A // session block is delimited by its "package=" line, so state and metadata are // only read from the block that belongs to the package. func parsePlayback(dump, pkg string) *Playback { var found *Playback var cur *Playback for _, raw := range strings.Split(dump, "\n") { line := strings.TrimSpace(raw) if name, ok := strings.CutPrefix(line, "package="); ok { if cur != nil && cur.Package == pkg && found == nil { found = cur } cur = &Playback{Package: strings.TrimSpace(name)} continue } if cur == nil { continue } if m := playbackStateRe.FindStringSubmatch(line); m != nil { cur.State = m[1] continue } if m := metadataRe.FindStringSubmatch(line); m != nil && cur.Title == "" { cur.Title = strings.TrimSpace(m[1]) } } if cur != nil && cur.Package == pkg && found == nil { found = cur } if found == nil { return &Playback{Package: pkg, State: "NONE"} } if found.State == "" { found.State = "NONE" } return found } // MatchesText reports how many of the query's words appear in text. It is used // to confirm that the session is playing the song the recipe asked for. func MatchesText(text, query string) (matched, total int) { want := tokenize(query) if len(want) == 0 { return 0, 0 } have := map[string]bool{} for _, w := range tokenize(text) { have[w] = true } seen := map[string]bool{} for _, w := range want { if have[w] { seen[w] = true } } return len(seen), len(want) }