1076 lines
35 KiB
Dart
1076 lines
35 KiB
Dart
/// Account-free, device-local preferences and library state.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../data/api_service.dart';
|
|
import '../data/models.dart';
|
|
import '../data/content_policy.dart';
|
|
|
|
/// Movies and series share the poster endpoint; channels have their own IDs.
|
|
extension LocalTitleIdentity on TitleRecord {
|
|
String get libraryKey => '${isChannel ? 'channel' : 'poster'}:$id';
|
|
}
|
|
|
|
String _libraryKey(int id, String kind) =>
|
|
'${kind == 'channel' ? 'channel' : 'poster'}:$id';
|
|
|
|
class AppSettings {
|
|
AppSettings({
|
|
this.locale = 'en',
|
|
this.theme = 'dark',
|
|
this.wifiOnlyDownloads = false,
|
|
this.autoNext = true,
|
|
this.resumePlayback = true,
|
|
this.autoplay = false,
|
|
this.externalPlayer = false,
|
|
this.defaultQuality = 'ask',
|
|
this.playbackSpeed = 1.0,
|
|
this.skipSeconds = 10,
|
|
this.subtitleFontSize = 24.0,
|
|
this.subtitleColor = 0xFFFFFFFF,
|
|
this.subtitleBackground = 0x80000000,
|
|
this.subtitleDelay = 0.0,
|
|
this.audioDelay = 0.0,
|
|
this.orientation = 'auto',
|
|
List<int> preferredGenres = const [],
|
|
this.hideTrailers = false,
|
|
this.downloadNotifications = true,
|
|
this.dataSaver = false,
|
|
this.subtitleEnabled = true,
|
|
this.subtitleFontFamily = 'sans-serif',
|
|
this.videoFit = 'contain',
|
|
this.audioOnly = false,
|
|
this.playerVolume = 1.0,
|
|
this.rememberVolume = false,
|
|
this.backgroundAudio = false,
|
|
this.closeOnCompletion = false,
|
|
this.resumeRewindSeconds = 5,
|
|
this.parallelDownloads = 3,
|
|
this.compactCatalog = false,
|
|
this.introCompleted = false,
|
|
this.externalPlayerPackage = 'ask',
|
|
}) : preferredGenres = List.unmodifiable(preferredGenres);
|
|
|
|
final String locale, theme, defaultQuality, orientation;
|
|
final bool wifiOnlyDownloads,
|
|
autoNext,
|
|
resumePlayback,
|
|
autoplay,
|
|
externalPlayer,
|
|
hideTrailers,
|
|
downloadNotifications,
|
|
dataSaver;
|
|
final double playbackSpeed, subtitleFontSize, subtitleDelay, audioDelay;
|
|
final int skipSeconds, subtitleColor, subtitleBackground;
|
|
final List<int> preferredGenres;
|
|
final bool subtitleEnabled;
|
|
final String subtitleFontFamily;
|
|
final String videoFit;
|
|
final bool audioOnly;
|
|
final double playerVolume;
|
|
final bool rememberVolume;
|
|
final bool backgroundAudio;
|
|
final bool closeOnCompletion;
|
|
final int resumeRewindSeconds;
|
|
final int parallelDownloads;
|
|
final bool compactCatalog;
|
|
final bool introCompleted;
|
|
final String externalPlayerPackage;
|
|
|
|
/// Resolve generic preferences to platform font names rather than treating
|
|
/// CSS generic names as bundled Flutter fonts. Missing custom families still
|
|
/// fall back through these names and Flutter's system glyph fallback.
|
|
String? get resolvedSubtitleFontFamily {
|
|
final apple =
|
|
defaultTargetPlatform == TargetPlatform.iOS ||
|
|
defaultTargetPlatform == TargetPlatform.macOS;
|
|
final android = defaultTargetPlatform == TargetPlatform.android;
|
|
final windows = defaultTargetPlatform == TargetPlatform.windows;
|
|
return switch (subtitleFontFamily) {
|
|
'sans-serif' => null, // Flutter's actual platform default.
|
|
'serif' =>
|
|
apple || windows
|
|
? 'Times New Roman'
|
|
: android
|
|
? 'Noto Serif'
|
|
: 'DejaVu Serif',
|
|
'monospace' =>
|
|
apple
|
|
? 'Menlo'
|
|
: windows
|
|
? 'Consolas'
|
|
: android
|
|
? 'Droid Sans Mono'
|
|
: 'DejaVu Sans Mono',
|
|
_ => subtitleFontFamily,
|
|
};
|
|
}
|
|
|
|
List<String> get subtitleFontFamilyFallback =>
|
|
switch (defaultTargetPlatform) {
|
|
TargetPlatform.android => const [
|
|
'Roboto',
|
|
'Noto Sans Arabic',
|
|
'Noto Naskh Arabic',
|
|
],
|
|
TargetPlatform.iOS ||
|
|
TargetPlatform.macOS => const ['Helvetica Neue', 'Arial', 'Geeza Pro'],
|
|
TargetPlatform.windows => const ['Segoe UI', 'Arial', 'Tahoma'],
|
|
_ => const ['DejaVu Sans', 'Noto Sans', 'Noto Sans Arabic'],
|
|
};
|
|
|
|
AppSettings copyWith({
|
|
String? locale,
|
|
String? theme,
|
|
bool? wifiOnlyDownloads,
|
|
bool? autoNext,
|
|
bool? resumePlayback,
|
|
bool? autoplay,
|
|
bool? externalPlayer,
|
|
String? defaultQuality,
|
|
double? playbackSpeed,
|
|
int? skipSeconds,
|
|
double? subtitleFontSize,
|
|
int? subtitleColor,
|
|
int? subtitleBackground,
|
|
double? subtitleDelay,
|
|
double? audioDelay,
|
|
String? orientation,
|
|
List<int>? preferredGenres,
|
|
bool? hideTrailers,
|
|
bool? downloadNotifications,
|
|
bool? dataSaver,
|
|
bool? subtitleEnabled,
|
|
String? subtitleFontFamily,
|
|
String? videoFit,
|
|
bool? audioOnly,
|
|
double? playerVolume,
|
|
bool? rememberVolume,
|
|
bool? backgroundAudio,
|
|
bool? closeOnCompletion,
|
|
int? resumeRewindSeconds,
|
|
int? parallelDownloads,
|
|
bool? compactCatalog,
|
|
bool? introCompleted,
|
|
String? externalPlayerPackage,
|
|
}) => AppSettings(
|
|
locale: locale ?? this.locale,
|
|
theme: theme ?? this.theme,
|
|
wifiOnlyDownloads: wifiOnlyDownloads ?? this.wifiOnlyDownloads,
|
|
autoNext: autoNext ?? this.autoNext,
|
|
resumePlayback: resumePlayback ?? this.resumePlayback,
|
|
autoplay: autoplay ?? this.autoplay,
|
|
externalPlayer: externalPlayer ?? this.externalPlayer,
|
|
defaultQuality: defaultQuality ?? this.defaultQuality,
|
|
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
|
|
skipSeconds: skipSeconds ?? this.skipSeconds,
|
|
subtitleFontSize: subtitleFontSize ?? this.subtitleFontSize,
|
|
subtitleColor: subtitleColor ?? this.subtitleColor,
|
|
subtitleBackground: subtitleBackground ?? this.subtitleBackground,
|
|
subtitleDelay: subtitleDelay ?? this.subtitleDelay,
|
|
audioDelay: audioDelay ?? this.audioDelay,
|
|
orientation: orientation ?? this.orientation,
|
|
preferredGenres: preferredGenres ?? this.preferredGenres,
|
|
hideTrailers: hideTrailers ?? this.hideTrailers,
|
|
downloadNotifications: downloadNotifications ?? this.downloadNotifications,
|
|
dataSaver: dataSaver ?? this.dataSaver,
|
|
subtitleEnabled: subtitleEnabled ?? this.subtitleEnabled,
|
|
subtitleFontFamily: subtitleFontFamily ?? this.subtitleFontFamily,
|
|
videoFit: videoFit ?? this.videoFit,
|
|
audioOnly: audioOnly ?? this.audioOnly,
|
|
playerVolume: playerVolume ?? this.playerVolume,
|
|
rememberVolume: rememberVolume ?? this.rememberVolume,
|
|
backgroundAudio: backgroundAudio ?? this.backgroundAudio,
|
|
closeOnCompletion: closeOnCompletion ?? this.closeOnCompletion,
|
|
resumeRewindSeconds: resumeRewindSeconds ?? this.resumeRewindSeconds,
|
|
parallelDownloads: parallelDownloads ?? this.parallelDownloads,
|
|
compactCatalog: compactCatalog ?? this.compactCatalog,
|
|
introCompleted: introCompleted ?? this.introCompleted,
|
|
externalPlayerPackage: externalPlayerPackage ?? this.externalPlayerPackage,
|
|
);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'locale': locale,
|
|
'theme': theme,
|
|
'wifiOnlyDownloads': wifiOnlyDownloads,
|
|
'autoNext': autoNext,
|
|
'resumePlayback': resumePlayback,
|
|
'autoplay': autoplay,
|
|
'externalPlayer': externalPlayer,
|
|
'defaultQuality': defaultQuality,
|
|
'playbackSpeed': playbackSpeed,
|
|
'skipSeconds': skipSeconds,
|
|
'subtitleFontSize': subtitleFontSize,
|
|
'subtitleColor': subtitleColor,
|
|
'subtitleBackground': subtitleBackground,
|
|
'subtitleDelay': subtitleDelay,
|
|
'audioDelay': audioDelay,
|
|
'orientation': orientation,
|
|
'preferredGenres': preferredGenres,
|
|
'hideTrailers': hideTrailers,
|
|
'downloadNotifications': downloadNotifications,
|
|
'dataSaver': dataSaver,
|
|
'subtitleEnabled': subtitleEnabled,
|
|
'subtitleFontFamily': subtitleFontFamily,
|
|
'videoFit': videoFit,
|
|
'audioOnly': audioOnly,
|
|
'playerVolume': playerVolume,
|
|
'rememberVolume': rememberVolume,
|
|
'backgroundAudio': backgroundAudio,
|
|
'closeOnCompletion': closeOnCompletion,
|
|
'resumeRewindSeconds': resumeRewindSeconds,
|
|
'parallelDownloads': parallelDownloads,
|
|
'compactCatalog': compactCatalog,
|
|
'introCompleted': introCompleted,
|
|
'externalPlayerPackage': externalPlayerPackage,
|
|
};
|
|
|
|
factory AppSettings.fromJson(Map<String, dynamic> json) {
|
|
bool boolean(String key, bool fallback) =>
|
|
json[key] is bool ? json[key] as bool : fallback;
|
|
String choice(String key, Set<String> allowed, String fallback) =>
|
|
allowed.contains(json[key]) ? json[key] as String : fallback;
|
|
double number(String key, double fallback, {double? min, double? max}) {
|
|
final value = json[key];
|
|
if (value is! num ||
|
|
!value.isFinite ||
|
|
(min != null && value < min) ||
|
|
(max != null && value > max)) {
|
|
return fallback;
|
|
}
|
|
return value.toDouble();
|
|
}
|
|
|
|
int integer(String key, int fallback, int min, int max) {
|
|
final value = json[key];
|
|
return value is int && value >= min && value <= max ? value : fallback;
|
|
}
|
|
|
|
String safeString(String key, String fallback, {bool package = false}) {
|
|
final value = json[key];
|
|
if (value is! String ||
|
|
value.trim().isEmpty ||
|
|
value.length > 120 ||
|
|
!ContentPolicy.allowsText(value) ||
|
|
RegExp(r'[\x00-\x1f\x7f]').hasMatch(value) ||
|
|
(package &&
|
|
value != 'ask' &&
|
|
!RegExp(r'^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$')
|
|
.hasMatch(value))) {
|
|
return fallback;
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
final genres = json['preferredGenres'];
|
|
return AppSettings(
|
|
locale: choice('locale', {'en', 'fa'}, 'en'),
|
|
theme: choice('theme', {'dark', 'light', 'system'}, 'dark'),
|
|
wifiOnlyDownloads: boolean('wifiOnlyDownloads', false),
|
|
autoNext: boolean('autoNext', true),
|
|
resumePlayback: boolean('resumePlayback', true),
|
|
autoplay: boolean('autoplay', false),
|
|
externalPlayer: boolean('externalPlayer', false),
|
|
defaultQuality: safeString('defaultQuality', 'ask'),
|
|
playbackSpeed: number('playbackSpeed', 1, min: 0.25, max: 4),
|
|
skipSeconds: integer('skipSeconds', 10, 1, 600),
|
|
subtitleFontSize: number('subtitleFontSize', 24, min: 8, max: 96),
|
|
subtitleColor: integer('subtitleColor', 0xFFFFFFFF, 0, 0xFFFFFFFF),
|
|
subtitleBackground: integer(
|
|
'subtitleBackground',
|
|
0x80000000,
|
|
0,
|
|
0xFFFFFFFF,
|
|
),
|
|
subtitleDelay: number('subtitleDelay', 0, min: -60, max: 60),
|
|
audioDelay: number('audioDelay', 0, min: -60, max: 60),
|
|
orientation: choice('orientation', {
|
|
'auto',
|
|
'portrait',
|
|
'landscape',
|
|
}, 'auto'),
|
|
preferredGenres: genres is List
|
|
? genres.whereType<int>().where((id) => id > 0).toSet().toList()
|
|
: const [],
|
|
hideTrailers: boolean('hideTrailers', false),
|
|
downloadNotifications: boolean('downloadNotifications', true),
|
|
dataSaver: boolean('dataSaver', false),
|
|
subtitleEnabled: boolean('subtitleEnabled', true),
|
|
subtitleFontFamily: safeString('subtitleFontFamily', 'sans-serif'),
|
|
videoFit: choice('videoFit', {'contain', 'cover', 'fill'}, 'contain'),
|
|
audioOnly: boolean('audioOnly', false),
|
|
playerVolume: number('playerVolume', 1, min: 0, max: 1),
|
|
rememberVolume: boolean('rememberVolume', false),
|
|
backgroundAudio: boolean('backgroundAudio', false),
|
|
closeOnCompletion: boolean('closeOnCompletion', false),
|
|
resumeRewindSeconds: integer('resumeRewindSeconds', 5, 0, 120),
|
|
parallelDownloads: integer('parallelDownloads', 3, 1, 8),
|
|
compactCatalog: boolean('compactCatalog', false),
|
|
introCompleted: boolean('introCompleted', true),
|
|
externalPlayerPackage: safeString(
|
|
'externalPlayerPackage',
|
|
'ask',
|
|
package: true,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WatchEntry {
|
|
WatchEntry({
|
|
required this.title,
|
|
this.episodeId,
|
|
this.episodeTitle = '',
|
|
this.sourceUrl = '',
|
|
this.positionSeconds = 0,
|
|
this.durationSeconds = 0,
|
|
DateTime? updatedAt,
|
|
}) : updatedAt = (updatedAt ?? DateTime.now()).toUtc();
|
|
|
|
final TitleRecord title;
|
|
final int? episodeId;
|
|
final String episodeTitle, sourceUrl;
|
|
final double positionSeconds, durationSeconds;
|
|
final DateTime updatedAt;
|
|
|
|
double get progress =>
|
|
durationSeconds > 0 &&
|
|
durationSeconds.isFinite &&
|
|
positionSeconds.isFinite
|
|
? (positionSeconds / durationSeconds).clamp(0.0, 1.0)
|
|
: 0;
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'title': title.toJson(),
|
|
'episodeId': episodeId,
|
|
'episodeTitle': episodeTitle,
|
|
'sourceUrl': sourceUrl,
|
|
'positionSeconds': positionSeconds,
|
|
'durationSeconds': durationSeconds,
|
|
'updatedAt': updatedAt.toIso8601String(),
|
|
};
|
|
|
|
factory WatchEntry.fromJson(Map<String, dynamic> json) {
|
|
final episode = json['episodeId'];
|
|
final position = json['positionSeconds'];
|
|
final duration = json['durationSeconds'];
|
|
final timestamp = json['updatedAt'];
|
|
final date = timestamp is String ? DateTime.tryParse(timestamp) : null;
|
|
if ((episode != null && (episode is! int || episode <= 0)) ||
|
|
json['episodeTitle'] is! String ||
|
|
json['sourceUrl'] is! String ||
|
|
position is! num ||
|
|
!position.isFinite ||
|
|
position < 0 ||
|
|
duration is! num ||
|
|
!duration.isFinite ||
|
|
duration < 0 ||
|
|
date == null) {
|
|
throw const FormatException('Invalid watch entry');
|
|
}
|
|
return WatchEntry(
|
|
title: _parseTitles([json['title']]).single,
|
|
episodeId: episode as int?,
|
|
episodeTitle: ContentPolicy.sanitizeText(json['episodeTitle'] as String),
|
|
sourceUrl: ContentPolicy.allowsUrl(json['sourceUrl'] as String)
|
|
? json['sourceUrl'] as String
|
|
: '',
|
|
positionSeconds: position.toDouble(),
|
|
durationSeconds: duration.toDouble(),
|
|
updatedAt: date,
|
|
);
|
|
}
|
|
}
|
|
|
|
class LocalCollection {
|
|
LocalCollection({
|
|
required this.id,
|
|
required this.name,
|
|
List<TitleRecord> titles = const [],
|
|
}) : titles = List.unmodifiable(titles);
|
|
|
|
final String id, name;
|
|
final List<TitleRecord> titles;
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'name': name,
|
|
'titles': titles.map((title) => title.toJson()).toList(),
|
|
};
|
|
|
|
factory LocalCollection.fromJson(Map<String, dynamic> json) {
|
|
final id = json['id'];
|
|
final name = json['name'];
|
|
if (id is! String ||
|
|
id.trim().isEmpty ||
|
|
name is! String ||
|
|
name.trim().isEmpty) {
|
|
throw const FormatException('Invalid local collection');
|
|
}
|
|
return LocalCollection(
|
|
id: id,
|
|
name: ContentPolicy.allowsText(name) ? name : 'Local collection',
|
|
titles: _parseTitles(json['titles']),
|
|
);
|
|
}
|
|
}
|
|
|
|
bool _allowsPersistedTitle(Map<String, dynamic> json) =>
|
|
json['type'] != 'channel' &&
|
|
json['kind'] != 'channel' &&
|
|
ContentPolicy.allowsTitleJson(json);
|
|
|
|
/// A deliberate completion marker, not a playback position.
|
|
class EpisodeWatchMarker {
|
|
const EpisodeWatchMarker({
|
|
required this.title,
|
|
required this.episodeId,
|
|
this.episodeTitle = '',
|
|
});
|
|
final TitleRecord title;
|
|
final int episodeId;
|
|
final String episodeTitle;
|
|
Map<String, dynamic> toJson() => {
|
|
'title': title.toJson(),
|
|
'episodeId': episodeId,
|
|
'episodeTitle': episodeTitle,
|
|
};
|
|
}
|
|
|
|
List<EpisodeWatchMarker> _parseMarkers(dynamic value) {
|
|
if (value == null) return const []; // Backward-compatible v1 extension.
|
|
if (value is! List) {
|
|
throw const FormatException('Expected watched episode markers');
|
|
}
|
|
final seen = <String>{};
|
|
final result = <EpisodeWatchMarker>[];
|
|
for (final item in value) {
|
|
final json = _object(item);
|
|
final titleJson = _object(json['title']);
|
|
if (!_allowsPersistedTitle(titleJson)) continue;
|
|
final title = _parseTitles([titleJson]).single;
|
|
final episodeId = json['episodeId'];
|
|
if (episodeId is! int ||
|
|
episodeId <= 0 ||
|
|
json['episodeTitle'] is! String ||
|
|
!seen.add('${title.libraryKey}:$episodeId')) {
|
|
throw const FormatException(
|
|
'Invalid or duplicate watched episode marker',
|
|
);
|
|
}
|
|
result.add(
|
|
EpisodeWatchMarker(
|
|
title: title,
|
|
episodeId: episodeId,
|
|
episodeTitle: ContentPolicy.sanitizeText(
|
|
json['episodeTitle'] as String,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
class HistoryContinuation {
|
|
const HistoryContinuation({
|
|
required this.title,
|
|
required this.source,
|
|
this.episode,
|
|
this.playlist = const [],
|
|
this.alternatives = const [],
|
|
});
|
|
final TitleRecord title;
|
|
final MediaSource source;
|
|
final EpisodeRecord? episode;
|
|
final List<EpisodeRecord> playlist;
|
|
final List<MediaSource> alternatives;
|
|
}
|
|
|
|
class _Library {
|
|
_Library({
|
|
AppSettings? prefs,
|
|
List<TitleRecord> saved = const [],
|
|
List<WatchEntry> history = const [],
|
|
List<String> searchHistory = const [],
|
|
List<LocalCollection> collections = const [],
|
|
List<EpisodeWatchMarker> watchedEpisodes = const [],
|
|
}) : prefs = prefs ?? AppSettings(),
|
|
saved = List.unmodifiable(saved),
|
|
history = List.unmodifiable(history),
|
|
searchHistory = List.unmodifiable(searchHistory),
|
|
collections = List.unmodifiable(collections),
|
|
watchedEpisodes = List.unmodifiable(watchedEpisodes);
|
|
|
|
final AppSettings prefs;
|
|
final List<TitleRecord> saved;
|
|
final List<WatchEntry> history;
|
|
final List<String> searchHistory;
|
|
final List<LocalCollection> collections;
|
|
final List<EpisodeWatchMarker> watchedEpisodes;
|
|
|
|
_Library copyWith({
|
|
AppSettings? prefs,
|
|
List<TitleRecord>? saved,
|
|
List<WatchEntry>? history,
|
|
List<String>? searchHistory,
|
|
List<LocalCollection>? collections,
|
|
List<EpisodeWatchMarker>? watchedEpisodes,
|
|
}) => _Library(
|
|
prefs: prefs ?? this.prefs,
|
|
saved: saved ?? this.saved,
|
|
history: history ?? this.history,
|
|
searchHistory: searchHistory ?? this.searchHistory,
|
|
collections: collections ?? this.collections,
|
|
watchedEpisodes: watchedEpisodes ?? this.watchedEpisodes,
|
|
);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'format': 'reelbox.library',
|
|
'version': 1,
|
|
'settings': prefs.toJson(),
|
|
'saved': saved.map((title) => title.toJson()).toList(),
|
|
'history': history.map((entry) => entry.toJson()).toList(),
|
|
'searchHistory': searchHistory,
|
|
'collections': collections.map((item) => item.toJson()).toList(),
|
|
'watchedEpisodes': watchedEpisodes.map((item) => item.toJson()).toList(),
|
|
};
|
|
|
|
factory _Library.fromJson(Map<String, dynamic> json) {
|
|
if (json['format'] != 'reelbox.library' ||
|
|
json['version'] != 1 ||
|
|
json['version'] is! int) {
|
|
throw const FormatException('Unsupported Reelbox library format/version');
|
|
}
|
|
final titles = _parseTitles(json['saved']);
|
|
final watch = json['history'];
|
|
if (watch is! List) throw const FormatException('Expected watch history');
|
|
final entries = watch
|
|
.where(
|
|
(value) => _allowsPersistedTitle(_object(_object(value)['title'])),
|
|
)
|
|
.map((value) => WatchEntry.fromJson(_object(value)))
|
|
.toList();
|
|
final keys = <String>{};
|
|
for (final entry in entries) {
|
|
if (!keys.add('${entry.title.libraryKey}:${entry.episodeId}')) {
|
|
throw const FormatException('Duplicate watch entry');
|
|
}
|
|
}
|
|
entries.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
|
final rawCollections = json['collections'];
|
|
if (rawCollections is! List) {
|
|
throw const FormatException('Expected local collections');
|
|
}
|
|
final collections = rawCollections
|
|
.map((item) => LocalCollection.fromJson(_object(item)))
|
|
.toList();
|
|
final collectionIds = <String>{};
|
|
if (collections.any((item) => !collectionIds.add(item.id))) {
|
|
throw const FormatException('Duplicate local collection ID');
|
|
}
|
|
return _Library(
|
|
prefs: AppSettings.fromJson(_object(json['settings'])),
|
|
saved: titles,
|
|
history: entries.take(1000).toList(),
|
|
searchHistory: _parseSearchHistory(json['searchHistory']),
|
|
collections: collections,
|
|
watchedEpisodes: _parseMarkers(json['watchedEpisodes']),
|
|
);
|
|
}
|
|
}
|
|
|
|
List<String> _parseSearchHistory(dynamic value) {
|
|
if (value is! List || value.any((item) => item is! String)) {
|
|
throw const FormatException('Expected search history strings');
|
|
}
|
|
final seen = <String>{};
|
|
return value
|
|
.cast<String>()
|
|
.map((item) => item.trim())
|
|
.where(
|
|
(item) =>
|
|
item.isNotEmpty &&
|
|
ContentPolicy.allowsText(item) &&
|
|
seen.add(item.toLowerCase()),
|
|
)
|
|
.take(30)
|
|
.toList();
|
|
}
|
|
|
|
Map<String, dynamic> _object(dynamic value) {
|
|
if (value is! Map<String, dynamic>) {
|
|
throw const FormatException('Expected a JSON object');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
List<TitleRecord> _parseTitles(dynamic value) {
|
|
if (value is! List) throw const FormatException('Expected a title list');
|
|
final ids = <String>{};
|
|
return value
|
|
.where((item) => _allowsPersistedTitle(_object(item)))
|
|
.map((item) {
|
|
final json = _object(item);
|
|
final id = json['id'];
|
|
if (id is! int || id <= 0 || json['title'] is! String) {
|
|
throw const FormatException('Invalid or duplicate title');
|
|
}
|
|
final title = TitleRecord.fromJson(json);
|
|
if (!ids.add(title.libraryKey)) {
|
|
throw const FormatException('Invalid or duplicate title');
|
|
}
|
|
return title;
|
|
})
|
|
.where((title) => _allowsPersistedTitle(title.toJson()))
|
|
.toList();
|
|
}
|
|
|
|
/// All mutations are queued, durably written as one versioned JSON value, then
|
|
/// published. Await returned futures to surface storage and validation errors.
|
|
class AppState extends ChangeNotifier {
|
|
AppState({ApiService? api, this._storage}) : api = api ?? ApiService();
|
|
|
|
static const storageKey = 'reelbox.local.library.v1';
|
|
final ApiService api;
|
|
SharedPreferences? _storage;
|
|
_Library _data = _Library();
|
|
Future<void> _pending = Future.value();
|
|
bool _loaded = false;
|
|
bool _disposed = false;
|
|
bool _initialized = false;
|
|
|
|
int _apiRevision = 0;
|
|
int get apiRevision => _apiRevision;
|
|
bool get usesIranCatalogue => api.baseUrl == ApiService.iranBaseUrl;
|
|
|
|
/// Only explicit user selection changes catalogue origin; local data is untouched.
|
|
Future<void> selectApi(String baseUrl) => _enqueue(() async {
|
|
if (baseUrl != ApiService.iranBaseUrl) {
|
|
throw const ApiException('Iran Catalogue is the supported API server.');
|
|
}
|
|
await _load();
|
|
if (api.baseUrl == baseUrl) return;
|
|
await api.setBaseUrl(baseUrl);
|
|
_apiRevision++;
|
|
if (!_disposed) notifyListeners();
|
|
});
|
|
|
|
AppSettings get prefs => _data.prefs;
|
|
String get locale => prefs.locale;
|
|
List<int> get selectedGenres => prefs.preferredGenres;
|
|
List<TitleRecord> get saved => _data.saved;
|
|
List<WatchEntry> get history => _data.history;
|
|
List<String> get searchHistory => _data.searchHistory;
|
|
List<LocalCollection> get collections => _data.collections;
|
|
|
|
/// Legacy integer-only callers refer to the poster namespace.
|
|
bool hasSaved(int id, {String kind = 'poster'}) =>
|
|
saved.any((title) => title.libraryKey == _libraryKey(id, kind));
|
|
|
|
Future<T> _enqueue<T>(Future<T> Function() action) {
|
|
final result = _pending.then((_) {
|
|
if (_disposed) throw StateError('AppState is disposed');
|
|
return action();
|
|
});
|
|
// A failed caller-visible operation must not poison later operations.
|
|
_pending = result.then<void>(
|
|
(_) {},
|
|
onError: (Object error, StackTrace stack) {},
|
|
);
|
|
return result;
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
if (_loaded) return;
|
|
final storage = _storage ??= await SharedPreferences.getInstance();
|
|
final encoded = storage.getString(storageKey);
|
|
if (encoded != null) {
|
|
final migrated = _Library.fromJson(_object(jsonDecode(encoded)));
|
|
final sanitized = jsonEncode(migrated.toJson());
|
|
// Retain the same v1 namespace and all allowed user data. Do not reset a
|
|
// failed/corrupt load; surface errors and keep its original backup intact.
|
|
if (sanitized != encoded &&
|
|
!await storage.setString(storageKey, sanitized)) {
|
|
throw StateError('Could not persist the local library migration');
|
|
}
|
|
_data = migrated;
|
|
}
|
|
_loaded = true;
|
|
}
|
|
|
|
Future<void> initialize({bool discover = true}) => _enqueue(() async {
|
|
if (_initialized) return;
|
|
await _load();
|
|
await api.initialize(discover: discover);
|
|
_initialized = true;
|
|
if (!_disposed) notifyListeners();
|
|
});
|
|
|
|
Future<void> _commit(_Library next) async {
|
|
final safeNext = _Library.fromJson(next.toJson());
|
|
final encoded = jsonEncode(safeNext.toJson());
|
|
final success = await _storage!.setString(storageKey, encoded);
|
|
if (!success) {
|
|
throw StateError('Could not persist the local Reelbox library');
|
|
}
|
|
_data = safeNext;
|
|
if (!_disposed) notifyListeners();
|
|
}
|
|
|
|
Future<void> toggleSaved(TitleRecord title) => _enqueue(() async {
|
|
await _load();
|
|
if (!_allowsPersistedTitle(title.toJson())) return;
|
|
if (title.id <= 0) throw ArgumentError.value(title.id, 'title.id');
|
|
await _commit(
|
|
_data.copyWith(
|
|
saved: hasSaved(title.id, kind: title.type)
|
|
? saved
|
|
.where((item) => item.libraryKey != title.libraryKey)
|
|
.toList()
|
|
: [title, ...saved],
|
|
),
|
|
);
|
|
});
|
|
|
|
Future<void> removeSaved(int id, {String kind = 'poster'}) =>
|
|
_enqueue(() async {
|
|
await _load();
|
|
await _commit(
|
|
_data.copyWith(
|
|
saved: saved
|
|
.where((title) => title.libraryKey != _libraryKey(id, kind))
|
|
.toList(),
|
|
),
|
|
);
|
|
});
|
|
|
|
Future<void> updateSettings(AppSettings settings) => _enqueue(() async {
|
|
await _load();
|
|
await _commit(
|
|
_data.copyWith(prefs: AppSettings.fromJson(settings.toJson())),
|
|
);
|
|
});
|
|
|
|
Future<void> rememberSearch(String query) => _enqueue(() async {
|
|
await _load();
|
|
final text = query.trim();
|
|
if (text.isEmpty || !ContentPolicy.allowsText(text)) return;
|
|
await _commit(
|
|
_data.copyWith(
|
|
searchHistory: [
|
|
text,
|
|
...searchHistory.where(
|
|
(item) => item.toLowerCase() != text.toLowerCase(),
|
|
),
|
|
].take(30).toList(),
|
|
),
|
|
);
|
|
});
|
|
|
|
Future<void> removeSearch(String query) => _enqueue(() async {
|
|
await _load();
|
|
final key = query.trim().toLowerCase();
|
|
await _commit(
|
|
_data.copyWith(
|
|
searchHistory: searchHistory
|
|
.where((item) => item.toLowerCase() != key)
|
|
.toList(),
|
|
),
|
|
);
|
|
});
|
|
|
|
Future<void> clearSearchHistory() => _enqueue(() async {
|
|
await _load();
|
|
await _commit(_data.copyWith(searchHistory: []));
|
|
});
|
|
|
|
Future<void> recordWatch(
|
|
TitleRecord title, {
|
|
int? episodeId,
|
|
String episodeTitle = '',
|
|
String sourceUrl = '',
|
|
double positionSeconds = 0,
|
|
double durationSeconds = 0,
|
|
}) => _enqueue(() async {
|
|
await _load();
|
|
if (title.id <= 0 ||
|
|
(episodeId != null && episodeId <= 0) ||
|
|
!positionSeconds.isFinite ||
|
|
positionSeconds < 0 ||
|
|
!durationSeconds.isFinite ||
|
|
durationSeconds < 0) {
|
|
throw ArgumentError(
|
|
'Watch IDs and positions must be valid and non-negative',
|
|
);
|
|
}
|
|
if (!_allowsPersistedTitle(title.toJson())) return;
|
|
final entry = WatchEntry(
|
|
title: title,
|
|
episodeId: episodeId,
|
|
episodeTitle: episodeTitle,
|
|
sourceUrl: sourceUrl,
|
|
positionSeconds: positionSeconds,
|
|
durationSeconds: durationSeconds,
|
|
);
|
|
await _commit(
|
|
_data.copyWith(
|
|
history: [
|
|
entry,
|
|
...history.where(
|
|
(item) =>
|
|
item.title.libraryKey != title.libraryKey ||
|
|
item.episodeId != episodeId,
|
|
),
|
|
].take(1000).toList(),
|
|
),
|
|
);
|
|
});
|
|
|
|
double resumeSeconds(int titleId, {int? episodeId, String kind = 'poster'}) {
|
|
if (!prefs.resumePlayback) return 0;
|
|
for (final entry in history) {
|
|
if (entry.title.libraryKey == _libraryKey(titleId, kind) &&
|
|
entry.episodeId == episodeId) {
|
|
return entry.positionSeconds;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/// Removes only the exact namespace/title/episode tuple.
|
|
/// Legacy callers default to posters; null identifies a non-episode entry.
|
|
Future<void> removeHistory(
|
|
int titleId, {
|
|
int? episodeId,
|
|
String kind = 'poster',
|
|
}) => _enqueue(() async {
|
|
await _load();
|
|
await _commit(
|
|
_data.copyWith(
|
|
history: history
|
|
.where(
|
|
(entry) =>
|
|
entry.title.libraryKey != _libraryKey(titleId, kind) ||
|
|
entry.episodeId != episodeId,
|
|
)
|
|
.toList(),
|
|
),
|
|
);
|
|
});
|
|
|
|
Future<void> clearHistory() => _enqueue(() async {
|
|
await _load();
|
|
await _commit(_data.copyWith(history: []));
|
|
});
|
|
|
|
Future<LocalCollection> createCollection(String name) => _enqueue(() async {
|
|
await _load();
|
|
final text = name.trim();
|
|
if (text.isEmpty || !ContentPolicy.allowsText(text)) {
|
|
throw ArgumentError('A valid local collection name is required');
|
|
}
|
|
final base = 'local-${DateTime.now().microsecondsSinceEpoch}';
|
|
var id = base;
|
|
var suffix = 0;
|
|
while (collections.any((item) => item.id == id)) {
|
|
id = '$base-${++suffix}';
|
|
}
|
|
final collection = LocalCollection(id: id, name: text);
|
|
await _commit(_data.copyWith(collections: [...collections, collection]));
|
|
return collection;
|
|
});
|
|
|
|
Future<void> deleteCollection(String collectionId) => _enqueue(() async {
|
|
await _load();
|
|
await _commit(
|
|
_data.copyWith(
|
|
collections: collections
|
|
.where((item) => item.id != collectionId)
|
|
.toList(),
|
|
),
|
|
);
|
|
});
|
|
|
|
Future<void> toggleCollectionTitle(String collectionId, TitleRecord title) =>
|
|
_enqueue(() async {
|
|
await _load();
|
|
if (!_allowsPersistedTitle(title.toJson())) return;
|
|
if (title.id <= 0) throw ArgumentError.value(title.id, 'title.id');
|
|
final index = collections.indexWhere((item) => item.id == collectionId);
|
|
if (index < 0) throw StateError('Local collection not found');
|
|
final collection = collections[index];
|
|
final hasTitle = collection.titles.any(
|
|
(item) => item.libraryKey == title.libraryKey,
|
|
);
|
|
final updated = LocalCollection(
|
|
id: collection.id,
|
|
name: collection.name,
|
|
titles: hasTitle
|
|
? collection.titles
|
|
.where((item) => item.libraryKey != title.libraryKey)
|
|
.toList()
|
|
: [...collection.titles, title],
|
|
);
|
|
final next = [...collections];
|
|
next[index] = updated;
|
|
await _commit(_data.copyWith(collections: next));
|
|
});
|
|
|
|
int get savedCount => saved.length;
|
|
int get historyCount => history.length;
|
|
int get collectionCount => collections.length;
|
|
int get watchedEpisodeCount => _data.watchedEpisodes.length;
|
|
List<EpisodeWatchMarker> get watchedEpisodes => _data.watchedEpisodes;
|
|
|
|
bool isEpisodeWatched(int titleId, int episodeId) =>
|
|
_data.watchedEpisodes.any(
|
|
(entry) => entry.title.id == titleId && entry.episodeId == episodeId,
|
|
);
|
|
|
|
Future<void> setEpisodeWatched(
|
|
TitleRecord title,
|
|
EpisodeRecord episode,
|
|
bool watched,
|
|
) => _enqueue(() async {
|
|
await _load();
|
|
if (!_allowsPersistedTitle(title.toJson())) return;
|
|
if (title.id <= 0 || episode.id <= 0) {
|
|
throw ArgumentError('Positive title and episode IDs are required');
|
|
}
|
|
final next = _data.watchedEpisodes
|
|
.where(
|
|
(entry) =>
|
|
entry.title.libraryKey != title.libraryKey ||
|
|
entry.episodeId != episode.id,
|
|
)
|
|
.toList();
|
|
if (watched) {
|
|
next.add(
|
|
EpisodeWatchMarker(
|
|
title: title,
|
|
episodeId: episode.id,
|
|
episodeTitle: ContentPolicy.sanitizeText(episode.title),
|
|
),
|
|
);
|
|
}
|
|
await _commit(_data.copyWith(watchedEpisodes: next));
|
|
});
|
|
|
|
/// Does not touch audio delay, playback preferences, downloads or the library.
|
|
Future<void> resetSubtitleStyle() => _enqueue(() async {
|
|
await _load();
|
|
await _commit(
|
|
_data.copyWith(
|
|
prefs: prefs.copyWith(
|
|
subtitleEnabled: true,
|
|
subtitleFontFamily: 'sans-serif',
|
|
subtitleFontSize: 24,
|
|
subtitleColor: 0xFFFFFFFF,
|
|
subtitleBackground: 0x80000000,
|
|
subtitleDelay: 0,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
/// Stored source URLs can expire. Always fetch current metadata before playing.
|
|
Future<HistoryContinuation> resolveHistory(WatchEntry entry) async {
|
|
if (!_allowsPersistedTitle(entry.title.toJson())) {
|
|
throw StateError('This title is no longer available');
|
|
}
|
|
final title = await api.poster(entry.title.id);
|
|
if (!_allowsPersistedTitle(title.toJson())) {
|
|
throw StateError('This title is no longer available');
|
|
}
|
|
EpisodeRecord? episode;
|
|
List<EpisodeRecord> playlist = const [];
|
|
if (entry.episodeId != null) {
|
|
playlist = (await api.seasons(title.id))
|
|
.expand((season) => season.episodes)
|
|
.toList();
|
|
final matches = playlist.where((item) => item.id == entry.episodeId);
|
|
if (matches.isEmpty) {
|
|
throw StateError('This episode is no longer available');
|
|
}
|
|
episode = matches.first;
|
|
}
|
|
final sources = (episode?.sources ?? title.sources).where((source) {
|
|
final uri = Uri.tryParse(source.url);
|
|
return uri != null &&
|
|
{'http', 'https'}.contains(uri.scheme) &&
|
|
uri.host.isNotEmpty &&
|
|
ContentPolicy.allowsUrl(source.url) &&
|
|
(!prefs.hideTrailers || !source.isTrailer);
|
|
}).toList();
|
|
if (sources.isEmpty) {
|
|
throw StateError('No current playable sources. Open the title to retry.');
|
|
}
|
|
final preferred = sources.where(
|
|
(source) =>
|
|
source.url == entry.sourceUrl ||
|
|
(prefs.defaultQuality != 'ask' &&
|
|
prefs.defaultQuality != 'auto' &&
|
|
source.quality.contains(prefs.defaultQuality)),
|
|
);
|
|
return HistoryContinuation(
|
|
title: title,
|
|
source: preferred.firstOrNull ?? sources.first,
|
|
episode: episode,
|
|
playlist: playlist,
|
|
alternatives: sources,
|
|
);
|
|
}
|
|
|
|
String exportLibrary() => jsonEncode(_data.toJson());
|
|
|
|
Future<void> importLibrary(String text) => _enqueue(() async {
|
|
if (utf8.encode(text).length > 8 * 1024 * 1024) {
|
|
throw const FormatException('Library backup exceeds 8 MB');
|
|
}
|
|
final next = _Library.fromJson(_object(jsonDecode(text)));
|
|
await _load();
|
|
await _commit(next);
|
|
});
|
|
|
|
Future<void> clearLocalData() => _enqueue(() async {
|
|
await _load();
|
|
await _commit(_Library());
|
|
});
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
class AppScope extends InheritedNotifier<AppState> {
|
|
const AppScope({
|
|
super.key,
|
|
AppState? state,
|
|
AppState? notifier,
|
|
required super.child,
|
|
}) : assert(state != null || notifier != null),
|
|
super(notifier: state ?? notifier);
|
|
|
|
static AppState of(BuildContext context) {
|
|
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
|
if (scope?.notifier == null) throw StateError('AppScope is missing');
|
|
return scope!.notifier!;
|
|
}
|
|
|
|
static AppState read(BuildContext context) {
|
|
final scope = context.getInheritedWidgetOfExactType<AppScope>();
|
|
if (scope?.notifier == null) throw StateError('AppScope is missing');
|
|
return scope!.notifier!;
|
|
}
|
|
}
|
|
|
|
String tr(BuildContext context, String english, String persian) =>
|
|
AppScope.of(context).locale == 'fa' ? persian : english;
|