259 lines
6.5 KiB
Dart
259 lines
6.5 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../models/video_item.dart';
|
|
|
|
class LibraryService {
|
|
LibraryService._();
|
|
|
|
static final LibraryService instance = LibraryService._();
|
|
|
|
static const String _historyKey = 'youtube_personal.history.v1';
|
|
static const String _favoritesKey = 'youtube_personal.favorites.v1';
|
|
static const String _positionsKey = 'youtube_personal.positions.v1';
|
|
static const String _watchCountsKey =
|
|
'youtube_personal.watch_counts.v1';
|
|
static const int _maxHistoryItems = 100;
|
|
|
|
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
|
|
|
|
final ValueNotifier<List<VideoItem>> history =
|
|
ValueNotifier<List<VideoItem>>(const []);
|
|
|
|
final ValueNotifier<List<VideoItem>> favorites =
|
|
ValueNotifier<List<VideoItem>>(const []);
|
|
|
|
final Map<String, int> _positionsMs = <String, int>{};
|
|
final Map<String, int> _watchCounts = <String, int>{};
|
|
|
|
/// Changes whenever a real video opening increments the user's own view
|
|
/// counter. The Channels page uses this to refresh its ranking.
|
|
final ValueNotifier<int> watchRevision = ValueNotifier<int>(0);
|
|
|
|
Future<void> initialize() async {
|
|
history.value = await _readList(_historyKey);
|
|
favorites.value = await _readList(_favoritesKey);
|
|
|
|
_positionsMs
|
|
..clear()
|
|
..addAll(await _readPositions());
|
|
|
|
_watchCounts
|
|
..clear()
|
|
..addAll(await _readIntMap(_watchCountsKey));
|
|
|
|
// Migration for installs created before personal watch counters existed.
|
|
// Every video already present in history counts as at least one personal
|
|
// view, without inventing repeat views that we never recorded.
|
|
var migrated = false;
|
|
for (final video in history.value) {
|
|
if ((_watchCounts[video.id] ?? 0) <= 0) {
|
|
_watchCounts[video.id] = 1;
|
|
migrated = true;
|
|
}
|
|
}
|
|
|
|
if (migrated) {
|
|
await _writeWatchCounts();
|
|
}
|
|
}
|
|
|
|
int watchCountForVideo(String videoId) {
|
|
return _watchCounts[videoId] ?? 0;
|
|
}
|
|
|
|
Future<void> addToHistory(
|
|
VideoItem video, {
|
|
bool incrementWatchCount = true,
|
|
}) async {
|
|
final updated = <VideoItem>[
|
|
video,
|
|
...history.value.where((item) => item.id != video.id),
|
|
];
|
|
|
|
if (updated.length > _maxHistoryItems) {
|
|
updated.removeRange(_maxHistoryItems, updated.length);
|
|
}
|
|
|
|
history.value = List<VideoItem>.unmodifiable(updated);
|
|
await _writeList(_historyKey, history.value);
|
|
|
|
if (incrementWatchCount) {
|
|
_watchCounts.update(
|
|
video.id,
|
|
(value) => value + 1,
|
|
ifAbsent: () => 1,
|
|
);
|
|
|
|
await _writeWatchCounts();
|
|
watchRevision.value++;
|
|
}
|
|
}
|
|
|
|
bool isFavorite(String videoId) {
|
|
return favorites.value.any((item) => item.id == videoId);
|
|
}
|
|
|
|
Future<bool> toggleFavorite(VideoItem video) async {
|
|
final updated = [...favorites.value];
|
|
final index = updated.indexWhere((item) => item.id == video.id);
|
|
|
|
if (index >= 0) {
|
|
updated.removeAt(index);
|
|
} else {
|
|
updated.insert(0, video);
|
|
}
|
|
|
|
favorites.value = List<VideoItem>.unmodifiable(updated);
|
|
await _writeList(_favoritesKey, favorites.value);
|
|
|
|
return isFavorite(video.id);
|
|
}
|
|
|
|
Duration? getPlaybackPosition(String videoId) {
|
|
final milliseconds = _positionsMs[videoId];
|
|
|
|
if (milliseconds == null || milliseconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return Duration(milliseconds: milliseconds);
|
|
}
|
|
|
|
Future<void> savePlaybackPosition(
|
|
String videoId,
|
|
Duration position, {
|
|
Duration? duration,
|
|
}) async {
|
|
final positionMs = position.inMilliseconds;
|
|
|
|
// On ne mémorise pas les toutes premières secondes.
|
|
if (positionMs < const Duration(seconds: 5).inMilliseconds) {
|
|
return;
|
|
}
|
|
|
|
// Si la vidéo est pratiquement terminée, on efface la reprise.
|
|
if (duration != null && duration > Duration.zero) {
|
|
final remaining = duration - position;
|
|
|
|
if (remaining <= const Duration(seconds: 15)) {
|
|
await clearPlaybackPosition(videoId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
_positionsMs[videoId] = positionMs;
|
|
await _writePositions();
|
|
}
|
|
|
|
Future<void> clearPlaybackPosition(String videoId) async {
|
|
if (_positionsMs.remove(videoId) != null) {
|
|
await _writePositions();
|
|
}
|
|
}
|
|
|
|
Future<void> removeFromHistory(String videoId) async {
|
|
final updated = history.value.where((item) => item.id != videoId).toList();
|
|
history.value = List<VideoItem>.unmodifiable(updated);
|
|
await _writeList(_historyKey, history.value);
|
|
}
|
|
|
|
Future<void> clearHistory() async {
|
|
history.value = const [];
|
|
await _preferences.remove(_historyKey);
|
|
}
|
|
|
|
Future<void> clearFavorites() async {
|
|
favorites.value = const [];
|
|
await _preferences.remove(_favoritesKey);
|
|
}
|
|
|
|
Future<List<VideoItem>> _readList(String key) async {
|
|
final raw = await _preferences.getString(key);
|
|
|
|
if (raw == null || raw.isEmpty) {
|
|
return const [];
|
|
}
|
|
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
|
|
if (decoded is! List) {
|
|
return const [];
|
|
}
|
|
|
|
return decoded
|
|
.whereType<Map>()
|
|
.map(
|
|
(item) => VideoItem.fromJson(
|
|
Map<String, dynamic>.from(item),
|
|
),
|
|
)
|
|
.toList(growable: false);
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
Future<Map<String, int>> _readPositions() {
|
|
return _readIntMap(_positionsKey);
|
|
}
|
|
|
|
Future<Map<String, int>> _readIntMap(String key) async {
|
|
final raw = await _preferences.getString(key);
|
|
|
|
if (raw == null || raw.isEmpty) {
|
|
return const {};
|
|
}
|
|
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
|
|
if (decoded is! Map) {
|
|
return const {};
|
|
}
|
|
|
|
final result = <String, int>{};
|
|
|
|
for (final entry in decoded.entries) {
|
|
final value = entry.value;
|
|
|
|
if (entry.key is String && value is num) {
|
|
result[entry.key as String] = value.toInt();
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (_) {
|
|
return const {};
|
|
}
|
|
}
|
|
|
|
Future<void> _writeList(
|
|
String key,
|
|
List<VideoItem> videos,
|
|
) async {
|
|
final payload = jsonEncode(
|
|
videos.map((video) => video.toJson()).toList(growable: false),
|
|
);
|
|
|
|
await _preferences.setString(key, payload);
|
|
}
|
|
|
|
Future<void> _writePositions() async {
|
|
await _preferences.setString(
|
|
_positionsKey,
|
|
jsonEncode(_positionsMs),
|
|
);
|
|
}
|
|
|
|
Future<void> _writeWatchCounts() async {
|
|
await _preferences.setString(
|
|
_watchCountsKey,
|
|
jsonEncode(_watchCounts),
|
|
);
|
|
}
|
|
}
|