Files
youtube_personal/lib/services/library_service.dart
T

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),
);
}
}