This commit is contained in:
Forrest 2026-07-29 14:14:18 -04:00
commit d205277cdd
182 changed files with 22978 additions and 0 deletions

View file

@ -0,0 +1,244 @@
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();
}