Phase 3 DoD reached end to end: `artemis run spotify_play query="Rolling Stones Honky Tonk"` played the right song in 13 steps / 35.3s, with playback paused first so the pass proves the row tap started it. Two things stopped it before that, both found on the real phone: - The playback check asserted on an on-screen "Pause" label that Spotify never shows: its transport button keeps the label "Play" while music is playing. The recipe now ends with a `playing` step that reads the app's media session (state PLAYING) and confirms the track title contains the query words. A failure reports the state and the track it found. - tap_row pressed the wrong row. Once a track is loaded its row carries a `Paused` state indicator, and counting that content description as row text dropped the top hit below an identical duplicate further down, so the tap went to y=1443 instead of y=878. Rows are now scored on their title and subtitle only (state lives in content descriptions), and scores within rowScoreTolerance are treated as equal so the higher row wins, because Spotify already ranks by relevance. internal/ui/rows_test.go reproduces that results screen, including the state indicator and the duplicate row. docs/phase-3-spotify-recipe.md records the successful run, the independent media-session and audio-output reads, and the failure that preceded it.
117 lines
3.2 KiB
Go
117 lines
3.2 KiB
Go
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)
|
|
}
|