3054e8702b
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>
86 lines
3.3 KiB
Dart
86 lines
3.3 KiB
Dart
import 'package:dio/dio.dart';
|
|
|
|
import '../../models/playlist.dart';
|
|
|
|
/// Wire shape for GET /api/playlists. Server splits owned vs. public so
|
|
/// the UI can present them as different sections; we preserve that split
|
|
/// to give the integrations page room to grow.
|
|
class PlaylistsList {
|
|
const PlaylistsList({required this.owned, required this.public});
|
|
final List<Playlist> owned;
|
|
final List<Playlist> public;
|
|
|
|
factory PlaylistsList.empty() =>
|
|
const PlaylistsList(owned: [], public: []);
|
|
|
|
/// Concatenated view for callers that don't care about the split.
|
|
List<Playlist> get all => [...owned, ...public];
|
|
}
|
|
|
|
class PlaylistsApi {
|
|
PlaylistsApi(this._dio);
|
|
final Dio _dio;
|
|
|
|
/// GET /api/playlists?kind=user|system|all. Server returns
|
|
/// `{"owned": [...], "public": [...]}`. Owned is the caller's own
|
|
/// playlists, filtered by the kind param (default "user"). Public
|
|
/// is other users' shared playlists; not filtered by kind.
|
|
Future<PlaylistsList> list({String kind = 'user'}) async {
|
|
final r = await _dio.get<Map<String, dynamic>>(
|
|
'/api/playlists',
|
|
queryParameters: {'kind': kind},
|
|
);
|
|
final body = r.data ?? const <String, dynamic>{};
|
|
List<Playlist> parse(String key) {
|
|
final raw = (body[key] as List?) ?? const [];
|
|
return raw
|
|
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
|
|
.toList(growable: false);
|
|
}
|
|
return PlaylistsList(owned: parse('owned'), public: parse('public'));
|
|
}
|
|
|
|
Future<PlaylistDetail> get(String id) async {
|
|
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
|
|
return PlaylistDetail.fromJson(r.data ?? const {});
|
|
}
|
|
|
|
/// GET /api/playlists/system/{variant}/shuffle (#415 stage 2/3).
|
|
/// Same shape as get() but tracks are server-ordered rotation-aware
|
|
/// (unplayed-this-rotation first; resets when exhausted). Model
|
|
/// variant uses underscores; the route segment is hyphenated.
|
|
/// Intentionally uncached — varies per play.
|
|
Future<PlaylistDetail> systemShuffle(String variant) async {
|
|
final seg = variant == 'for_you' ? 'for-you' : variant;
|
|
final r = await _dio.get<Map<String, dynamic>>(
|
|
'/api/playlists/system/$seg/shuffle',
|
|
);
|
|
return PlaylistDetail.fromJson(r.data ?? const {});
|
|
}
|
|
|
|
/// POST /api/playlists/{id}/tracks. Owner only; server returns the
|
|
/// playlist detail with the new rows.
|
|
Future<void> appendTracks(String playlistId, List<String> trackIds) async {
|
|
await _dio.post<void>(
|
|
'/api/playlists/$playlistId/tracks',
|
|
data: {'track_ids': trackIds},
|
|
);
|
|
}
|
|
|
|
/// POST /api/playlists/system/{variant}/refresh. Synchronously
|
|
/// rebuilds the caller's system playlist for the given variant
|
|
/// ("for_you" | "discover") and returns the new playlist id, or
|
|
/// null when the library is empty so there's nothing to build.
|
|
///
|
|
/// The variant uses underscores in the model (system_variant) but
|
|
/// the route segment is hyphenated ("for-you"), so map here.
|
|
Future<String?> refreshSystem(String variant) async {
|
|
final segment = variant == 'for_you' ? 'for-you' : variant;
|
|
final r = await _dio.post<Map<String, dynamic>>(
|
|
'/api/playlists/system/$segment/refresh',
|
|
data: const <String, dynamic>{},
|
|
);
|
|
return (r.data ?? const {})['playlist_id'] as String?;
|
|
}
|
|
}
|