feat(flutter): #415 stage 3 — play-events reporter + rotation wiring

The Flutter client previously reported NO plays — mobile listening
never reached play_events, so history, recommendation scoring,
ListenBrainz scrobbles, and #415 rotation all missed mobile entirely.
Operator chose to close that gap properly as part of Stage 3.

New:
- EventsApi (api/endpoints/events.dart): play_started/ended/skipped.
- PlayEventsReporter (player/play_events_reporter.dart): state
  machine over (track id, playing) mirroring the web dispatcher.
  Persists an opaque client_id in secure storage. Deliberate
  divergence from web: a track change inside a queue is classified
  ended-vs-skipped by whether the prior track reached ~its duration
  (3s tolerance), instead of web's blanket "track change = skip"
  which would mark every naturally-finished in-queue track a skip
  and dilute recommendation skip-ratios — the exact failure mode
  that motivated doing this properly. Fail-safe: no-ops when there's
  no audio handler (tests / no-audio env). App-lifecycle paused/
  detached closes an open row as a best-effort skip (web pagehide
  parity). Wired in app.dart postFrame.
- PlaylistsApi.systemShuffle(variant): GET the rotation-aware order.

Wiring:
- audio_handler: _queueSource carried through setQueueFromTracks
  (source param); preserved across internal skipToQueueItem rebuild.
- player_provider.playTracks: source param → setQueueFromTracks.
- PlaylistCard: system playlists fetch systemShuffle and play as-is
  tagged with source (no client shuffle — server already ordered).
- playlist_detail_screen: header Play + per-track tap tag source for
  system playlists so rotation advances from any entry point.

Known/flagged separately: the web dispatcher likely has the same
false-skip-on-advance issue; not fixed here to keep #415 scoped and
clients' wire behavior comparable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 08:15:32 -04:00
parent 179519689b
commit 3054e8702b
8 changed files with 325 additions and 11 deletions
@@ -212,6 +212,9 @@ class _Body extends ConsumerWidget {
ref.read(playerActionsProvider).playTracks(
playableRefs,
initialIndex: startIdx >= 0 ? startIdx : 0,
source: detail.playlist.isSystem
? detail.playlist.systemVariant
: null,
);
// Keep liveTrack referenced to avoid an unused-variable
// warning while we leave hooks for menu wiring later.
@@ -293,7 +296,10 @@ class _Header extends ConsumerWidget {
FilledButton.icon(
onPressed: () {
final refs = playable.map(_toTrackRef).toList(growable: false);
ref.read(playerActionsProvider).playTracks(refs);
ref.read(playerActionsProvider).playTracks(
refs,
source: p.isSystem ? p.systemVariant : null,
);
},
icon: const Icon(Icons.play_arrow),
label: const Text('Play'),
@@ -155,7 +155,13 @@ class PlaylistCard extends ConsumerWidget {
/// index 0. Mirrors the web PlaylistCard's onPlayClick.
Future<void> _playPlaylist(WidgetRef ref) async {
final api = await ref.read(playlistsApiProvider.future);
final detail = await api.get(playlist.id);
// System playlists: fetch the server's rotation-aware order
// (#415) and play it as-is, tagged with the source so the
// play-events reporter advances that playlist's rotation. User
// playlists keep stored order, untagged.
final detail = playlist.isSystem
? await api.systemShuffle(playlist.systemVariant!)
: await api.get(playlist.id);
final refs = <TrackRef>[];
for (final t in detail.tracks) {
if (t.trackId == null) continue;
@@ -171,13 +177,14 @@ class PlaylistCard extends ConsumerWidget {
));
}
if (refs.isEmpty) return;
// System playlists default to shuffle so replaying within a day
// doesn't deliver the identical experience. User playlists keep
// stored order.
// System playlists already arrive in rotation-aware order from
// the server (#415) — play as-is, tagged with the variant so the
// reporter advances rotation. No client shuffle. User playlists
// play in stored order, untagged.
await ref.read(playerActionsProvider).playTracks(
refs,
initialIndex: 0,
shuffle: playlist.isSystem,
source: playlist.isSystem ? playlist.systemVariant : null,
);
}
}