244 lines
7 KiB
Dart
244 lines
7 KiB
Dart
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<String, dynamic> toJson() => {
|
|
'songId': songId,
|
|
'title': title,
|
|
'playedAt': playedAt.toIso8601String(),
|
|
'album': album,
|
|
'albumId': albumId,
|
|
'artist': artist,
|
|
'artistId': artistId,
|
|
'coverArt': coverArt,
|
|
'duration': duration,
|
|
};
|
|
|
|
factory PlayRecord.fromJson(Map<String, dynamic> 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<List<PlayRecord>> {
|
|
HistoryController() : super(const []) {
|
|
_load();
|
|
}
|
|
|
|
static const int _maxRecords = 10000;
|
|
File? _file;
|
|
|
|
Future<void> _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>()
|
|
.map((e) => PlayRecord.fromJson(e.cast<String, dynamic>()))
|
|
.toList();
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Corrupt/missing history is non-fatal — start empty.
|
|
}
|
|
}
|
|
|
|
Future<void> 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<void> _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<PlayRecord> recentSongs(List<PlayRecord> history, {int limit = 20}) {
|
|
final seen = <String>{};
|
|
final out = <PlayRecord>[];
|
|
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<PlayRecord> recentAlbums(List<PlayRecord> history, {int limit = 12}) {
|
|
final seen = <String>{};
|
|
final out = <PlayRecord>[];
|
|
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<RediscoverArtist> rediscover(
|
|
List<PlayRecord> 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 = <String, RediscoverArtist>{};
|
|
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();
|
|
}
|