import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import '../subsonic/models.dart'; /// One play event, mirroring Ratune's `PlayRecord` (`history.rs`). class PlayRecord { PlayRecord({ required this.songId, required this.title, required this.playedAt, this.album, this.albumId, this.artist, this.artistId, this.coverArt, this.duration, }); final String songId; final String title; final DateTime playedAt; final String? album; final String? albumId; final String? artist; final String? artistId; final String? coverArt; final int? duration; Map toJson() => { 'songId': songId, 'title': title, 'playedAt': playedAt.toIso8601String(), 'album': album, 'albumId': albumId, 'artist': artist, 'artistId': artistId, 'coverArt': coverArt, 'duration': duration, }; factory PlayRecord.fromJson(Map j) => PlayRecord( songId: j['songId'] as String? ?? '', title: j['title'] as String? ?? 'Untitled', playedAt: DateTime.tryParse(j['playedAt'] as String? ?? '') ?? DateTime.fromMillisecondsSinceEpoch(0), album: j['album'] as String?, albumId: j['albumId'] as String?, artist: j['artist'] as String?, artistId: j['artistId'] as String?, coverArt: j['coverArt'] as String?, duration: (j['duration'] as num?)?.toInt(), ); /// Reconstruct a playable [Song] from this record (enough for streaming). Song toSong() => Song( id: songId, title: title, album: album, albumId: albumId, artist: artist, artistId: artistId, coverArt: coverArt, duration: duration, ); factory PlayRecord.fromSong(Song s, DateTime at) => PlayRecord( songId: s.id, title: s.title ?? 'Untitled', playedAt: at, album: s.album, albumId: s.albumId, artist: s.artist, artistId: s.artistId, coverArt: s.coverArt, duration: s.duration, ); } /// A rediscover suggestion — an artist not heard recently. class RediscoverArtist { RediscoverArtist({ required this.artistId, required this.name, required this.lastPlayed, required this.playCount, }); final String artistId; final String name; final DateTime lastPlayed; final int playCount; } /// Owns the persistent play history (newest first, capped at [_maxRecords]). class HistoryController extends StateNotifier> { HistoryController() : super(const []) { _load(); } static const int _maxRecords = 10000; File? _file; Future _load() async { try { final dir = await getApplicationSupportDirectory(); _file = File('${dir.path}/play_history.json'); if (await _file!.exists()) { final raw = jsonDecode(await _file!.readAsString()); if (raw is List) { state = raw .whereType() .map((e) => PlayRecord.fromJson(e.cast())) .toList(); } } } catch (_) { // Corrupt/missing history is non-fatal — start empty. } } Future record(Song song, {DateTime? at}) async { if (song.id.isEmpty) return; final rec = PlayRecord.fromSong(song, at ?? DateTime.now()); final next = [rec, ...state]; if (next.length > _maxRecords) next.removeRange(_maxRecords, next.length); state = next; unawaited(_persist()); } Future _persist() async { try { await _file?.writeAsString( jsonEncode(state.map((e) => e.toJson()).toList()), ); } catch (_) {} } } // ---- Pure derivations over a history list ------------------------------- /// Distinct recently-played songs (most recent first). List recentSongs(List history, {int limit = 20}) { final seen = {}; final out = []; for (final r in history) { if (seen.add(r.songId)) { out.add(r); if (out.length >= limit) break; } } return out; } /// Distinct recently-played albums (most recent first). List recentAlbums(List history, {int limit = 12}) { final seen = {}; final out = []; for (final r in history) { final key = r.albumId; if (key == null) continue; if (seen.add(key)) { out.add(r); if (out.length >= limit) break; } } return out; } /// Rediscover: artists you've heard before but aren't listening to now, biased /// toward low play counts, then sampled for variety (Ratune /// `history.rs:101-167`). [seed] drives the re-roll. /// /// The desktop original only surfaces artists last heard more than [minDays] /// days ago. That's dead weight on a young history (everything is recent), so /// we fall back to your least-recently-played artists — minus the couple you /// just heard — whenever too few have genuinely aged out. List rediscover( List history, { int count = 6, int minDays = 3, int seed = 0, DateTime? now, }) { final ref = now ?? DateTime.now(); final cutoff = ref.subtract(Duration(days: minDays)); final byArtist = {}; for (final r in history) { final id = r.artistId; if (id == null || r.artist == null) continue; final existing = byArtist[id]; if (existing == null) { byArtist[id] = RediscoverArtist( artistId: id, name: r.artist!, lastPlayed: r.playedAt, playCount: 1, ); } else { byArtist[id] = RediscoverArtist( artistId: id, name: existing.name, lastPlayed: r.playedAt.isAfter(existing.lastPlayed) ? r.playedAt : existing.lastPlayed, playCount: existing.playCount + 1, ); } } final all = byArtist.values.toList(); if (all.length <= 1) return const []; // Primary: artists genuinely not heard in the last [minDays]. var candidates = all.where((a) => a.lastPlayed.isBefore(cutoff)).toList(); // Fallback for young histories: everything except the artists you've heard // most recently (so we don't suggest what's playing now), which keeps the // "you're neglecting these" spirit without an absolute age wall. if (candidates.length < count) { final byRecency = [...all]..sort((a, b) => b.lastPlayed.compareTo(a.lastPlayed)); final recentlyHeard = byRecency.take(2).map((a) => a.artistId).toSet(); candidates = all.where((a) => !recentlyHeard.contains(a.artistId)).toList(); } // Prefer low play counts, then longest since last heard. candidates.sort((a, b) { final byCount = a.playCount.compareTo(b.playCount); if (byCount != 0) return byCount; return a.lastPlayed.compareTo(b.lastPlayed); }); // Weighted variety: sample from the top slice using the seed. final pool = candidates.take(count * 3).toList(); pool.shuffle(Random(seed)); return pool.take(count).toList(); }