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> history = ValueNotifier>(const []); final ValueNotifier> favorites = ValueNotifier>(const []); final Map _positionsMs = {}; final Map _watchCounts = {}; /// Changes whenever a real video opening increments the user's own view /// counter. The Channels page uses this to refresh its ranking. final ValueNotifier watchRevision = ValueNotifier(0); Future 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 addToHistory( VideoItem video, { bool incrementWatchCount = true, }) async { final updated = [ video, ...history.value.where((item) => item.id != video.id), ]; if (updated.length > _maxHistoryItems) { updated.removeRange(_maxHistoryItems, updated.length); } history.value = List.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 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.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 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 clearPlaybackPosition(String videoId) async { if (_positionsMs.remove(videoId) != null) { await _writePositions(); } } Future removeFromHistory(String videoId) async { final updated = history.value.where((item) => item.id != videoId).toList(); history.value = List.unmodifiable(updated); await _writeList(_historyKey, history.value); } Future clearHistory() async { history.value = const []; await _preferences.remove(_historyKey); } Future clearFavorites() async { favorites.value = const []; await _preferences.remove(_favoritesKey); } Future> _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( (item) => VideoItem.fromJson( Map.from(item), ), ) .toList(growable: false); } catch (_) { return const []; } } Future> _readPositions() { return _readIntMap(_positionsKey); } Future> _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 = {}; 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 _writeList( String key, List videos, ) async { final payload = jsonEncode( videos.map((video) => video.toJson()).toList(growable: false), ); await _preferences.setString(key, payload); } Future _writePositions() async { await _preferences.setString( _positionsKey, jsonEncode(_positionsMs), ); } Future _writeWatchCounts() async { await _preferences.setString( _watchCountsKey, jsonEncode(_watchCounts), ); } }