Files
artemis_control/internal/ui/media.go
Sam Rolfe c178f92851 feat: confirm playback from the media session, and stop demoting the top row
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.
2026-10-16 14:32:24 +11:00

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)
}