Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user