init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
244
lib/history/play_history.dart
Normal file
244
lib/history/play_history.dart
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue