feature: Sync 2.0 (#417)

breaking changes: Migrate from Isar to Drift database. Isar will be removed in future versions of the application the migration will only apply for as long as isar is included.
This commit is contained in:
PartyDonut 2025-07-27 13:14:37 +02:00 committed by GitHub
commit 3cbcecd692
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 3424 additions and 1285 deletions

View file

@ -1238,5 +1238,56 @@
},
"newUpdateFoundOnGithub": "Found a new update on Github",
"enableBackgroundPostersTitle": "Enable background posters",
"enableBackgroundPostersDesc": "Show random posters in applicable screens"
"enableBackgroundPostersDesc": "Show random posters in applicable screens",
"notificationDownloadingDownloading": "Downloading",
"notificationDownloadingPaused": "Download paused",
"notificationDownloadingFinished": "Download finished",
"notificationDownloadingError": "Download error",
"syncAllItemsTitle": "Sync all items from {itemName}?",
"@syncAllItemsTitle": {
"description": "syncAllItemsFrom",
"placeholders": {
"itemName": {
"type": "String"
}
}
},
"syncAllItemsDesc": "This will sync ({itemCount}) items from '{itemName}' to your device.\nThis can take a while depending on the amount of items.",
"@syncAllItemsDesc": {
"description": "syncAllitemsFromDesc",
"placeholders": {
"itemName": {
"type": "String"
},
"itemCount": {
"type": "int"
}
}
},
"syncDeleteAllItemsTitle": "Delete all synced items from {itemName}?",
"@syncDeleteAllItemsTitle": {
"description": "syncDeleteAllitemsFrom",
"placeholders": {
"itemName": {
"type": "String"
}
}
},
"syncDeleteAllItemsDesc": "This will delete all synced items from '{itemName}'.\nThis is permanent and you will need to re-sync ({itemCount}) files.",
"@syncDeleteAllItemsDesc": {
"description": "syncDeleteAllitemsFromDesc",
"placeholders": {
"itemName": {
"type": "String"
},
"itemCount": {
"type": "int"
}
}
},
"syncPauseAll": "Pause all",
"syncResumeAll": "Resume all",
"syncStopAll": "Stop all",
"syncDeleteAll": "Delete all files",
"syncAllFiles": "Sync all files"
}

View file

@ -8,9 +8,7 @@ import 'package:flutter/services.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:smtc_windows/smtc_windows.dart' if (dart.library.html) 'package:fladder/stubs/web/smtc_web.dart';
@ -20,7 +18,6 @@ import 'package:window_manager/window_manager.dart';
import 'package:fladder/l10n/generated/app_localizations.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/settings/arguments_model.dart';
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/providers/crash_log_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
@ -71,13 +68,10 @@ void main(List<String> args) async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
Directory isarPath = Directory("");
Directory applicationDirectory = Directory("");
if (!kIsWeb) {
applicationDirectory = await getApplicationDocumentsDirectory();
isarPath = Directory(path.joinAll([applicationDirectory.path, 'Fladder', 'Database']));
await isarPath.create(recursive: true);
}
if (_isDesktop) {
@ -98,16 +92,7 @@ void main(List<String> args) async {
applicationInfoProvider.overrideWith((ref) => applicationInfo),
crashLogProvider.overrideWith((ref) => crashProvider),
argumentsStateProvider.overrideWith((ref) => ArgumentsModel.fromArguments(args)),
syncProvider.overrideWith((ref) => SyncNotifier(
ref,
!kIsWeb
? Isar.open(
schemas: [ISyncedItemSchema],
directory: isarPath.path,
)
: null,
applicationDirectory,
))
syncProvider.overrideWith((ref) => SyncNotifier(ref, applicationDirectory))
],
child: AdaptiveLayoutBuilder(
child: (context) => const Main(),
@ -297,6 +282,7 @@ class _MainState extends ConsumerState<Main> with WindowListener, WidgetsBinding
},
builder: (context, child) => LocalizationContextWrapper(
child: ScaffoldMessenger(child: child ?? Container()),
currentLocale: language,
),
debugShowCheckedModeBanner: false,
darkTheme: darkTheme.copyWith(

View file

@ -26,7 +26,6 @@ class CredentialsModel {
Map<String, String> header(Ref ref) {
final application = ref.read(applicationInfoProvider);
final headers = {
'content-type': 'application/json',
'authorization':
'MediaBrowser Token="$token", Client="${application.name}", Device="${application.os}", DeviceId="$deviceId", Version="${application.version}"'
};

View file

@ -45,6 +45,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable {
final String? seriesName;
final int season;
final int episode;
final int? episodeEnd;
final List<Chapter> chapters;
final ItemLocation? location;
final DateTime? dateAired;
@ -52,6 +53,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable {
required this.seriesName,
required this.season,
required this.episode,
required this.episodeEnd,
this.chapters = const [],
this.location,
this.dateAired,
@ -134,12 +136,26 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable {
String seasonAnnotation(BuildContext context) => context.localized.season(1)[0];
String episodeAnnotation(BuildContext context) => context.localized.episode(1)[0];
int get episodeCount {
if (episodeEnd != null && episodeEnd! > episode) {
return episodeEnd! - episode + 1;
}
return 1;
}
String get episodeRange {
if (episodeEnd != null && episodeEnd! > episode) {
return "$episode-${episodeEnd!}";
}
return episode.toString();
}
String seasonEpisodeLabel(BuildContext context) {
return "${seasonAnnotation(context)}$season - ${episodeAnnotation(context)}$episode";
return "${seasonAnnotation(context)}$season - ${episodeAnnotation(context)}$episodeRange";
}
String seasonEpisodeLabelFull(BuildContext context) {
return "${context.localized.season(1)} $season - ${context.localized.episode(1)} $episode";
return "${context.localized.season(1)} $season - ${context.localized.episode(episodeCount)} $episodeRange";
}
String episodeLabel(BuildContext context) {
@ -147,7 +163,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable {
}
String get fullName {
return "$episode. $subText";
return "$episodeRange. $subText";
}
@override
@ -169,6 +185,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable {
primaryRatio: item.primaryImageAspectRatio,
season: item.parentIndexNumber ?? 0,
episode: item.indexNumber ?? 0,
episodeEnd: item.indexNumberEnd,
location: ItemLocation.fromDto(item.locationType),
parentImages: ImagesData.fromBaseItemParent(item, ref),
canDelete: item.canDelete,

View file

@ -31,6 +31,9 @@ class EpisodeModelMapper extends SubClassMapperBase<EpisodeModel> {
static int _$episode(EpisodeModel v) => v.episode;
static const Field<EpisodeModel, int> _f$episode =
Field('episode', _$episode);
static int? _$episodeEnd(EpisodeModel v) => v.episodeEnd;
static const Field<EpisodeModel, int> _f$episodeEnd =
Field('episodeEnd', _$episodeEnd);
static List<Chapter> _$chapters(EpisodeModel v) => v.chapters;
static const Field<EpisodeModel, List<Chapter>> _f$chapters =
Field('chapters', _$chapters, opt: true, def: const []);
@ -86,6 +89,7 @@ class EpisodeModelMapper extends SubClassMapperBase<EpisodeModel> {
#seriesName: _f$seriesName,
#season: _f$season,
#episode: _f$episode,
#episodeEnd: _f$episodeEnd,
#chapters: _f$chapters,
#location: _f$location,
#dateAired: _f$dateAired,
@ -120,6 +124,7 @@ class EpisodeModelMapper extends SubClassMapperBase<EpisodeModel> {
seriesName: data.dec(_f$seriesName),
season: data.dec(_f$season),
episode: data.dec(_f$episode),
episodeEnd: data.dec(_f$episodeEnd),
chapters: data.dec(_f$chapters),
location: data.dec(_f$location),
dateAired: data.dec(_f$dateAired),
@ -166,6 +171,7 @@ abstract class EpisodeModelCopyWith<$R, $In extends EpisodeModel, $Out>
{String? seriesName,
int? season,
int? episode,
int? episodeEnd,
List<Chapter>? chapters,
ItemLocation? location,
DateTime? dateAired,
@ -209,6 +215,7 @@ class _EpisodeModelCopyWithImpl<$R, $Out>
{Object? seriesName = $none,
int? season,
int? episode,
Object? episodeEnd = $none,
List<Chapter>? chapters,
Object? location = $none,
Object? dateAired = $none,
@ -230,6 +237,7 @@ class _EpisodeModelCopyWithImpl<$R, $Out>
if (seriesName != $none) #seriesName: seriesName,
if (season != null) #season: season,
if (episode != null) #episode: episode,
if (episodeEnd != $none) #episodeEnd: episodeEnd,
if (chapters != null) #chapters: chapters,
if (location != $none) #location: location,
if (dateAired != $none) #dateAired: dateAired,
@ -253,6 +261,7 @@ class _EpisodeModelCopyWithImpl<$R, $Out>
seriesName: data.get(#seriesName, or: $value.seriesName),
season: data.get(#season, or: $value.season),
episode: data.get(#episode, or: $value.episode),
episodeEnd: data.get(#episodeEnd, or: $value.episodeEnd),
chapters: data.get(#chapters, or: $value.chapters),
location: data.get(#location, or: $value.location),
dateAired: data.get(#dateAired, or: $value.dateAired),

View file

@ -50,6 +50,21 @@ class UserData with UserDataMappable {
Duration get playBackPosition => Duration(milliseconds: playbackPositionTicks ~/ 10000);
static UserData? determineLastUserData(List<UserData?> data) {
return data.where((data) => data != null).reduce((a, b) {
final aDate = a?.lastPlayed;
final bDate = b?.lastPlayed;
if (aDate != null && bDate != null) {
return aDate.isAfter(bDate) ? a : b;
} else if (aDate != null) {
return a;
} else {
return b;
}
});
}
factory UserData.fromMap(Map<String, dynamic> map) => UserDataMapper.fromMap(map);
factory UserData.fromJson(String json) => UserDataMapper.fromJson(json);
}

View file

@ -74,6 +74,9 @@ class SeasonModel extends ItemBaseModel with SeasonModelMappable {
episodes.firstWhereOrNull((element) => element.userData.played == false);
}
@override
bool get syncAble => episodes.isNotEmpty && episodes.any((element) => element.syncAble);
@override
ImagesData? get getPosters => images ?? parentImages;

View file

@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:chopper/chopper.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -26,7 +27,6 @@ import 'package:fladder/profiles/default_profile.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
@ -129,7 +129,7 @@ class PlaybackModelHelper {
await _createOfflinePlaybackModel(
newItem,
null,
ref.read(syncProvider.notifier).getSyncedItem(newItem),
await ref.read(syncProvider.notifier).getSyncedItem(newItem),
oldModel: currentModel,
);
if (newModel == null) return null;
@ -143,10 +143,10 @@ class PlaybackModelHelper {
SyncedItem? syncedItem, {
PlaybackModel? oldModel,
}) async {
final ItemBaseModel? syncedItemModel = ref.read(syncProvider.notifier).getItem(syncedItem);
final ItemBaseModel? syncedItemModel = syncedItem?.itemModel;
if (syncedItemModel == null || syncedItem == null || !syncedItem.dataFile.existsSync()) return null;
final children = ref.read(syncChildrenProvider(syncedItem));
final children = await ref.read(syncProvider.notifier).getChildren(syncedItem);
final syncedItems = children.where((element) => element.videoFile.existsSync()).toList();
final itemQueue = syncedItems.map((e) => e.createItemModel(ref));
@ -174,67 +174,81 @@ class PlaybackModelHelper {
final userId = ref.read(userProvider)?.id;
if (userId?.isEmpty == true) return null;
final queue = oldModel?.queue ?? libraryQueue ?? await collectQueue(item);
try {
final queue = oldModel?.queue ?? libraryQueue ?? await collectQueue(item);
final firstItemToPlay = switch (item) {
SeriesModel _ || SeasonModel _ => (queue.whereType<EpisodeModel>().toList().nextUp),
_ => item,
};
if (firstItemToPlay == null) return null;
final fullItem = (await api.usersUserIdItemsItemIdGet(itemId: firstItemToPlay.id)).body;
if (fullItem == null) return null;
SyncedItem? syncedItem = ref.read(syncProvider.notifier).getSyncedItem(fullItem);
final firstItemIsSynced = syncedItem != null && syncedItem.status == SyncStatus.complete;
final options = {
PlaybackType.directStream,
PlaybackType.transcode,
if (firstItemIsSynced) PlaybackType.offline,
};
if ((showPlaybackOptions || firstItemIsSynced) && context != null) {
final playbackType = await showPlaybackTypeSelection(
context: context,
options: options,
);
if (!context.mounted) return null;
return switch (playbackType) {
PlaybackType.directStream || PlaybackType.transcode => await _createServerPlaybackModel(
fullItem,
item.streamModel,
playbackType,
oldModel: oldModel,
libraryQueue: queue,
startPosition: startPosition,
),
PlaybackType.offline => await _createOfflinePlaybackModel(
fullItem,
item.streamModel,
syncedItem,
),
null => null
final firstItemToPlay = switch (item) {
SeriesModel _ || SeasonModel _ => (queue.whereType<EpisodeModel>().toList().nextUp),
_ => item,
};
} else {
return (await _createServerPlaybackModel(
fullItem,
item.streamModel,
PlaybackType.directStream,
startPosition: startPosition,
oldModel: oldModel,
libraryQueue: queue,
)) ??
await _createOfflinePlaybackModel(
fullItem,
item.streamModel,
syncedItem,
);
if (firstItemToPlay == null) return null;
final fullItem = (await api.usersUserIdItemsItemIdGet(itemId: firstItemToPlay.id)).body;
if (fullItem == null) return null;
SyncedItem? syncedItem = await ref.read(syncProvider.notifier).getSyncedItem(fullItem);
final firstItemIsSynced = syncedItem != null && syncedItem.status == TaskStatus.complete;
final options = {
PlaybackType.directStream,
PlaybackType.transcode,
if (firstItemIsSynced) PlaybackType.offline,
};
if ((showPlaybackOptions || firstItemIsSynced) && context != null) {
final playbackType = await showPlaybackTypeSelection(
context: context,
options: options,
);
if (!context.mounted) return null;
return switch (playbackType) {
PlaybackType.directStream || PlaybackType.transcode => await _createServerPlaybackModel(
fullItem,
item.streamModel,
playbackType,
oldModel: oldModel,
libraryQueue: queue,
startPosition: startPosition,
),
PlaybackType.offline => await _createOfflinePlaybackModel(
fullItem,
item.streamModel,
syncedItem,
),
null => null
};
} else {
return (await _createServerPlaybackModel(
fullItem,
item.streamModel,
PlaybackType.directStream,
startPosition: startPosition,
oldModel: oldModel,
libraryQueue: queue,
)) ??
await _createOfflinePlaybackModel(
fullItem,
item.streamModel,
syncedItem,
);
}
} catch (e) {
log(e.toString());
SyncedItem? syncedItem = await ref.read(syncProvider.notifier).getSyncedItem(item);
if (syncedItem != null) {
return await _createOfflinePlaybackModel(
item,
item.streamModel,
syncedItem,
oldModel: oldModel,
);
}
return null;
}
}

View file

@ -0,0 +1,193 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/user_provider.dart';
part 'database_item.g.dart';
@TableIndex(name: 'database_id', columns: {#id})
class DatabaseItems extends Table {
TextColumn get userId => text()();
TextColumn get id => text().withLength(min: 1)();
BoolColumn get syncing => boolean()();
TextColumn get sortName => text().nullable()();
TextColumn get parentId => text().nullable()();
TextColumn get path => text().nullable()();
IntColumn get fileSize => integer().nullable()();
TextColumn get videoFileName => text().nullable()();
TextColumn get trickPlayModel => text().nullable()();
TextColumn get mediaSegments => text().nullable()();
TextColumn get images => text().nullable()();
TextColumn get chapters => text().nullable()();
TextColumn get subtitles => text().nullable()();
TextColumn get userData => text().nullable()();
@override
Set<Column<Object>> get primaryKey => {id, userId};
}
@DriftDatabase(tables: [DatabaseItems])
class AppDatabase extends _$AppDatabase {
AppDatabase(this.ref, [QueryExecutor? executor]) : super(executor ?? _openConnection());
final Ref ref;
String get userId => ref.read(userProvider.select((value) => value?.id ?? ""));
@override
int get schemaVersion => 1;
Future<void> clearDatabase() {
return transaction(() async {
for (final table in allTables) {
await delete(table).go();
}
});
}
Selectable<SyncedItem> getItem(String id) =>
(select(databaseItems)..where((tbl) => tbl.id.equals(id) & tbl.userId.equals(userId))).map(databaseConverter);
Selectable<SyncedItem> getParent(String id) =>
(select(databaseItems)..where((tbl) => tbl.parentId.equals(id) & tbl.userId.equals(userId)))
.map(databaseConverter);
Selectable<SyncedItem> get getParentItems =>
((select(databaseItems)..where((tbl) => (tbl.parentId.isNull() & tbl.userId.equals(userId))))
..orderBy([(t) => OrderingTerm(expression: t.sortName)]))
.map(databaseConverter);
Selectable<SyncedItem> getChildren(String parentId) =>
((select(databaseItems)..where((tbl) => (tbl.parentId.equals(parentId) & tbl.userId.equals(userId))))
..orderBy([(t) => OrderingTerm(expression: t.sortName)]))
.map(databaseConverter);
Future<int> insertItem(SyncedItem item) async {
final itemExists = await getItem(item.id).getSingleOrNull();
if (itemExists != null) {
return (update(databaseItems)..where((tbl) => tbl.id.equals(item.id) & tbl.userId.equals(userId)))
.write(toDataBaseItem(item));
} else {
return into(databaseItems).insert(toDataBaseItem(item));
}
}
Future<List<SyncedItem>> getNestedChildren(SyncedItem root) async {
final itemType = root.createItemModel(ref)?.type;
if (itemType == null) return [];
final int maxDepth = switch (itemType) {
FladderItemType.episode => 0,
FladderItemType.movie => 0,
FladderItemType.season => 1,
FladderItemType.series => 2,
_ => 1,
};
final all = <SyncedItem>[];
List<SyncedItem> toProcess = [root];
if (maxDepth == 0) {
return [];
}
for (var i = 0; i < maxDepth; i++) {
final futures = toProcess.map((item) => getChildren(item.id).get());
final resultsList = await Future.wait(futures);
final children = resultsList.expand((r) => r).toList();
if (children.isEmpty) break;
all.addAll(children);
toProcess = children;
}
return all;
}
Future<void> insertMultipleEntries(List<SyncedItem> items) async {
await batch((batch) {
batch.insertAll(
databaseItems,
items.map(toDataBaseItem),
mode: InsertMode.insertOrReplace,
);
});
}
Future<void> deleteAllItems(List<SyncedItem> items) async => await batch((batch) {
batch.deleteWhere(databaseItems, (tbl) => tbl.id.isIn(items.map((e) => e.id)));
});
DatabaseItemsCompanion toDataBaseItem(SyncedItem item) {
return DatabaseItemsCompanion(
id: Value(item.id),
parentId: Value(item.parentId),
syncing: Value(item.syncing),
userId: Value(userId),
path: Value(item.path),
fileSize: Value(item.fileSize),
sortName: Value(item.sortName),
videoFileName: Value(item.videoFileName),
trickPlayModel: Value(item.fTrickPlayModel != null ? jsonEncode(item.fTrickPlayModel?.toJson()) : null),
mediaSegments: Value(item.mediaSegments != null ? jsonEncode(item.mediaSegments?.toJson()) : null),
images: Value(item.fImages != null ? jsonEncode(item.fImages?.toJson()) : null),
chapters: Value(jsonEncode(item.fChapters.map((e) => e.toJson()).toList())),
subtitles: Value(jsonEncode(item.subtitles.map((e) => e.toJson()).toList())),
userData: Value(item.userData != null ? jsonEncode(item.userData?.toJson()) : null),
);
}
SyncedItem databaseConverter(DatabaseItem dataItem) {
final syncedItem = SyncedItem(
id: dataItem.id,
userId: dataItem.userId,
parentId: dataItem.parentId,
sortName: dataItem.sortName,
syncing: dataItem.syncing,
path: dataItem.path,
fileSize: dataItem.fileSize,
videoFileName: dataItem.videoFileName,
fTrickPlayModel:
dataItem.trickPlayModel != null ? TrickPlayModel.fromJson(jsonDecode(dataItem.trickPlayModel!)) : null,
mediaSegments:
dataItem.mediaSegments != null ? MediaSegmentsModel.fromJson(jsonDecode(dataItem.mediaSegments!)) : null,
fImages: dataItem.images != null ? ImagesData.fromJson(jsonDecode(dataItem.images!)) : null,
fChapters: (dataItem.chapters != null && dataItem.chapters!.isNotEmpty)
? (jsonDecode(dataItem.chapters!) as List).map((e) => Chapter.fromJson(e)).toList()
: [],
subtitles: (dataItem.subtitles != null && dataItem.subtitles!.isNotEmpty)
? (jsonDecode(dataItem.subtitles!) as List).map((e) => SubStreamModel.fromJson(e)).toList()
: [],
userData: dataItem.userData != null ? UserData.fromJson(jsonDecode(dataItem.userData!)) : null,
);
return syncedItem.copyWith(
itemModel: syncedItem.createItemModel(ref),
);
}
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'syncedDatabase',
native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory,
),
// If you need web support, see https://drift.simonbinder.eu/platforms/web/
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -6,30 +6,13 @@ import 'package:fladder/models/syncing/sync_item.dart';
part 'i_synced_item.g.dart';
// extension IsarExtensions on String? {
// int get fastHash {
// if (this == null) return 0;
// var hash = 0xcbf29ce484222325;
// var i = 0;
// while (i < this!.length) {
// final codeUnit = this!.codeUnitAt(i++);
// hash ^= codeUnit >> 8;
// hash *= 0x100000001b3;
// hash ^= codeUnit & 0xFF;
// hash *= 0x100000001b3;
// }
// return hash;
// }
// }
@collection
class ISyncedItem {
String? userId;
String id;
bool syncing;
String? sortName;
@Index()
String? parentId;
String? path;
int? fileSize;

View file

@ -77,7 +77,16 @@ const ISyncedItemSchema = IsarGeneratedSchema(
type: IsarType.string,
),
],
indexes: [],
indexes: [
IsarIndexSchema(
name: 'parentId',
properties: [
"parentId",
],
unique: false,
hash: false,
),
],
),
converter: IsarObjectConverter<String, ISyncedItem>(
serialize: serializeISyncedItem,

View file

@ -18,7 +18,6 @@ import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/util/localization_helper.dart';
@ -44,9 +43,12 @@ class SyncedItem with _$SyncedItem {
@Default([]) List<Chapter> fChapters,
@Default([]) List<SubStreamModel> subtitles,
@UserDataJsonSerializer() UserData? userData,
// ignore: invalid_annotation_target
@JsonKey(includeFromJson: false, includeToJson: false) ItemBaseModel? itemModel,
}) = _SyncItem;
static String trickPlayPath = "TrickPlay";
static String chaptersPath = "Chapters";
List<Chapter> get chapters => fChapters.map((e) => e.copyWith(imageUrl: joinAll({"$path", e.imageUrl}))).toList();
@ -69,9 +71,9 @@ class SyncedItem with _$SyncedItem {
File get videoFile => File(joinAll(["$path", "$videoFileName"]));
Directory get directory => Directory(path ?? "");
SyncStatus get status => switch (videoFile.existsSync()) {
true => SyncStatus.complete,
_ => SyncStatus.partially,
TaskStatus get status => switch (videoFile.existsSync()) {
true => TaskStatus.complete,
_ => TaskStatus.notFound,
};
String? get taskId => task?.taskId;
@ -94,6 +96,7 @@ class SyncedItem with _$SyncedItem {
try {
await videoFile.delete();
await Directory(joinAll([directory.path, trickPlayPath])).delete(recursive: true);
await Directory(joinAll([directory.path, chaptersPath])).delete(recursive: true);
} catch (e) {
return false;
}
@ -101,10 +104,9 @@ class SyncedItem with _$SyncedItem {
return true;
}
List<SyncedItem> nestedChildren(WidgetRef ref) => ref.watch(syncChildrenProvider(this));
List<SyncedItem> getChildren(Ref ref) => ref.read(syncProvider.notifier).getChildren(this);
List<SyncedItem> getNestedChildren(Ref ref) => ref.read(syncProvider.notifier).getNestedChildren(this);
Future<List<SyncedItem>> getChildren(Ref ref) async => await ref.read(syncProvider.notifier).getChildren(this);
Future<List<SyncedItem>> getNestedChildren(Ref ref) async =>
await ref.read(syncProvider.notifier).getNestedChildren(this);
Future<int> get getDirSize async {
var files = await directory.list(recursive: true).toList();
@ -156,44 +158,45 @@ class SyncedItem with _$SyncedItem {
}
}
enum SyncStatus {
complete(
Color.fromARGB(255, 141, 214, 58),
IconsaxPlusLinear.tick_circle,
),
partially(
Color.fromARGB(255, 221, 135, 23),
IconsaxPlusLinear.more_circle,
),
;
const SyncStatus(this.color, this.icon);
final Color color;
String label(BuildContext context) {
return switch (this) {
SyncStatus.partially => context.localized.syncStatusPartially,
SyncStatus.complete => context.localized.syncStatusSynced,
};
}
final IconData icon;
}
extension StatusExtension on TaskStatus {
Color color(BuildContext context) => switch (this) {
TaskStatus.enqueued => Colors.blueAccent,
TaskStatus.running => Colors.limeAccent,
TaskStatus.complete => Colors.limeAccent,
TaskStatus.canceled || TaskStatus.notFound || TaskStatus.failed => Theme.of(context).colorScheme.error,
TaskStatus.waitingToRetry => Colors.yellowAccent,
TaskStatus.paused => Colors.orangeAccent,
IconData get icon => switch (this) {
TaskStatus.enqueued => IconsaxPlusLinear.calendar_circle,
TaskStatus.running => IconsaxPlusLinear.arrow_down_1,
TaskStatus.complete => IconsaxPlusLinear.tick_circle,
TaskStatus.notFound => IconsaxPlusLinear.warning_2,
TaskStatus.failed => IconsaxPlusLinear.tag_cross,
TaskStatus.canceled => IconsaxPlusLinear.tag_cross,
TaskStatus.waitingToRetry => IconsaxPlusLinear.clock,
TaskStatus.paused => IconsaxPlusLinear.pause_circle,
};
Color color(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return isDarkMode
? switch (this) {
TaskStatus.enqueued => Colors.blueAccent,
TaskStatus.running => Colors.greenAccent,
TaskStatus.complete => Colors.limeAccent,
TaskStatus.notFound => const Color.fromARGB(255, 221, 135, 23),
TaskStatus.canceled || TaskStatus.failed => Theme.of(context).colorScheme.error,
TaskStatus.waitingToRetry => Colors.yellowAccent,
TaskStatus.paused => Colors.tealAccent,
}
: switch (this) {
TaskStatus.enqueued => Colors.blue,
TaskStatus.running => Colors.green,
TaskStatus.complete => Colors.lime,
TaskStatus.notFound => const Color.fromARGB(255, 221, 135, 23),
TaskStatus.canceled || TaskStatus.failed => Theme.of(context).colorScheme.error,
TaskStatus.waitingToRetry => Colors.yellow,
TaskStatus.paused => Colors.teal,
};
}
String name(BuildContext context) => switch (this) {
TaskStatus.enqueued => context.localized.syncStatusEnqueued,
TaskStatus.running => context.localized.syncStatusRunning,
TaskStatus.complete => context.localized.syncStatusComplete,
TaskStatus.complete => context.localized.syncStatusSynced,
TaskStatus.notFound => context.localized.syncStatusNotFound,
TaskStatus.failed => context.localized.syncStatusFailed,
TaskStatus.canceled => context.localized.syncStatusCanceled,

View file

@ -31,7 +31,10 @@ mixin _$SyncedItem {
List<Chapter> get fChapters => throw _privateConstructorUsedError;
List<SubStreamModel> get subtitles => throw _privateConstructorUsedError;
@UserDataJsonSerializer()
UserData? get userData => throw _privateConstructorUsedError;
UserData? get userData =>
throw _privateConstructorUsedError; // ignore: invalid_annotation_target
@JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? get itemModel => throw _privateConstructorUsedError;
/// Create a copy of SyncedItem
/// with the given fields replaced by the non-null parameter values.
@ -61,7 +64,9 @@ abstract class $SyncedItemCopyWith<$Res> {
ImagesData? fImages,
List<Chapter> fChapters,
List<SubStreamModel> subtitles,
@UserDataJsonSerializer() UserData? userData});
@UserDataJsonSerializer() UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? itemModel});
$TrickPlayModelCopyWith<$Res>? get fTrickPlayModel;
}
@ -96,6 +101,7 @@ class _$SyncedItemCopyWithImpl<$Res, $Val extends SyncedItem>
Object? fChapters = null,
Object? subtitles = null,
Object? userData = freezed,
Object? itemModel = freezed,
}) {
return _then(_value.copyWith(
id: null == id
@ -158,6 +164,10 @@ class _$SyncedItemCopyWithImpl<$Res, $Val extends SyncedItem>
? _value.userData
: userData // ignore: cast_nullable_to_non_nullable
as UserData?,
itemModel: freezed == itemModel
? _value.itemModel
: itemModel // ignore: cast_nullable_to_non_nullable
as ItemBaseModel?,
) as $Val);
}
@ -199,7 +209,9 @@ abstract class _$$SyncItemImplCopyWith<$Res>
ImagesData? fImages,
List<Chapter> fChapters,
List<SubStreamModel> subtitles,
@UserDataJsonSerializer() UserData? userData});
@UserDataJsonSerializer() UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? itemModel});
@override
$TrickPlayModelCopyWith<$Res>? get fTrickPlayModel;
@ -233,6 +245,7 @@ class __$$SyncItemImplCopyWithImpl<$Res>
Object? fChapters = null,
Object? subtitles = null,
Object? userData = freezed,
Object? itemModel = freezed,
}) {
return _then(_$SyncItemImpl(
id: null == id
@ -295,6 +308,10 @@ class __$$SyncItemImplCopyWithImpl<$Res>
? _value.userData
: userData // ignore: cast_nullable_to_non_nullable
as UserData?,
itemModel: freezed == itemModel
? _value.itemModel
: itemModel // ignore: cast_nullable_to_non_nullable
as ItemBaseModel?,
));
}
}
@ -317,7 +334,8 @@ class _$SyncItemImpl extends _SyncItem {
this.fImages,
final List<Chapter> fChapters = const [],
final List<SubStreamModel> subtitles = const [],
@UserDataJsonSerializer() this.userData})
@UserDataJsonSerializer() this.userData,
@JsonKey(includeFromJson: false, includeToJson: false) this.itemModel})
: _fChapters = fChapters,
_subtitles = subtitles,
super._();
@ -369,10 +387,14 @@ class _$SyncItemImpl extends _SyncItem {
@override
@UserDataJsonSerializer()
final UserData? userData;
// ignore: invalid_annotation_target
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final ItemBaseModel? itemModel;
@override
String toString() {
return 'SyncedItem(id: $id, syncing: $syncing, parentId: $parentId, userId: $userId, path: $path, markedForDelete: $markedForDelete, sortName: $sortName, fileSize: $fileSize, videoFileName: $videoFileName, mediaSegments: $mediaSegments, fTrickPlayModel: $fTrickPlayModel, fImages: $fImages, fChapters: $fChapters, subtitles: $subtitles, userData: $userData)';
return 'SyncedItem(id: $id, syncing: $syncing, parentId: $parentId, userId: $userId, path: $path, markedForDelete: $markedForDelete, sortName: $sortName, fileSize: $fileSize, videoFileName: $videoFileName, mediaSegments: $mediaSegments, fTrickPlayModel: $fTrickPlayModel, fImages: $fImages, fChapters: $fChapters, subtitles: $subtitles, userData: $userData, itemModel: $itemModel)';
}
/// Create a copy of SyncedItem
@ -400,7 +422,9 @@ abstract class _SyncItem extends SyncedItem {
final ImagesData? fImages,
final List<Chapter> fChapters,
final List<SubStreamModel> subtitles,
@UserDataJsonSerializer() final UserData? userData}) = _$SyncItemImpl;
@UserDataJsonSerializer() final UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false)
final ItemBaseModel? itemModel}) = _$SyncItemImpl;
_SyncItem._() : super._();
@override
@ -433,7 +457,10 @@ abstract class _SyncItem extends SyncedItem {
List<SubStreamModel> get subtitles;
@override
@UserDataJsonSerializer()
UserData? get userData;
UserData? get userData; // ignore: invalid_annotation_target
@override
@JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? get itemModel;
/// Create a copy of SyncedItem
/// with the given fields replaced by the non-null parameter values.

View file

@ -72,20 +72,16 @@ class EpisodeDetailsProvider extends StateNotifier<EpisodeDetailModel> {
}
}
void _tryToCreateOfflineState(ItemBaseModel item) {
Future<void> _tryToCreateOfflineState(ItemBaseModel item) async {
final syncNotifier = ref.read(syncProvider.notifier);
final episodeModel = syncNotifier.getSyncedItem(item)?.createItemModel(ref) as EpisodeModel?;
final episodeModel = (await syncNotifier.getSyncedItem(item))?.itemModel as EpisodeModel?;
if (episodeModel == null) return;
final seriesSyncedItem = syncNotifier.getSyncedItem(episodeModel.parentBaseModel);
final seriesSyncedItem = await syncNotifier.getSyncedItem(episodeModel.parentBaseModel);
if (seriesSyncedItem == null) return;
final seriesModel = seriesSyncedItem.createItemModel(ref) as SeriesModel?;
final seriesModel = seriesSyncedItem.itemModel as SeriesModel?;
if (seriesModel == null) return;
final episodes = syncNotifier
.getNestedChildren(seriesSyncedItem)
.map(
(e) => e.createItemModel(ref),
)
.nonNulls
final episodes = (await syncNotifier.getNestedChildren(seriesSyncedItem))
.map((e) => e.itemModel)
.whereType<EpisodeModel>()
.toList();
state = state.copyWith(

View file

@ -35,11 +35,11 @@ class MovieDetails extends _$MovieDetails {
}
}
void _tryToCreateOfflineState(ItemBaseModel item) {
void _tryToCreateOfflineState(ItemBaseModel item) async {
final syncNotifier = ref.read(syncProvider.notifier);
final syncedItem = syncNotifier.getParentItem(item.id);
final syncedItem = await syncNotifier.getParentItem(item.id);
if (syncedItem == null) return;
final movieModel = syncedItem.createItemModel(ref) as MovieModel;
final movieModel = syncedItem.itemModel as MovieModel?;
state = movieModel;
}

View file

@ -6,7 +6,7 @@ part of 'movies_details_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$movieDetailsHash() => r'872ea61464ef8493c7e6c559c526377f1c8f6a6d';
String _$movieDetailsHash() => r'a9d8d2eeb7fa37652f25c1820b5e346efeeb59fc';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -29,7 +29,12 @@ class SeasonDetailsNotifier extends StateNotifier<SeasonModel?> {
seriesId: newState?.seriesId ?? "",
seasonId: newState?.id,
season: newState?.season,
fields: [ItemFields.overview],
fields: [
ItemFields.overview,
ItemFields.candelete,
ItemFields.candownload,
ItemFields.parentid,
],
);
newState = newState?.copyWith(episodes: EpisodeModel.episodesFromDto(episodes.body?.items, ref).toList());
state = newState;

View file

@ -32,20 +32,39 @@ class SeriesDetailViewNotifier extends StateNotifier<SeriesModel?> {
final response = await api.usersUserIdItemsItemIdGet(itemId: seriesModel.id);
if (response.body == null) return null;
newState = response.bodyOrThrow as SeriesModel;
final seasons = await api.showsSeriesIdSeasonsGet(seriesId: seriesModel.id);
newState = newState.copyWith(seasons: SeasonModel.seasonsFromDto(seasons.body?.items, ref));
final episodes = await api.showsSeriesIdEpisodesGet(seriesId: seriesModel.id, fields: [
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.overview,
ItemFields.candownload,
ItemFields.childcount,
]);
final newEpisodes = EpisodeModel.episodesFromDto(
episodes.body?.items,
ref,
);
final episodesCanDownload = newEpisodes.any((episode) => episode.canDownload == true);
final seasons = await api.showsSeriesIdSeasonsGet(seriesId: seriesModel.id, fields: [
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.overview,
ItemFields.candownload,
ItemFields.childcount,
]);
newState = newState.copyWith(
availableEpisodes: EpisodeModel.episodesFromDto(
episodes.body?.items,
ref,
),
seasons: SeasonModel.seasonsFromDto(seasons.body?.items, ref)
.map((element) => element.copyWith(
canDownload: true,
episodes: newEpisodes.where((episode) => episode.season == element.season).toList(),
))
.toList(),
);
newState = newState.copyWith(
canDownload: episodesCanDownload,
availableEpisodes: newEpisodes,
);
final related = await ref.read(relatedUtilityProvider).relatedContent(seriesModel.id);
@ -59,20 +78,16 @@ class SeriesDetailViewNotifier extends StateNotifier<SeriesModel?> {
Future<void> _tryToCreateOfflineState(ItemBaseModel series) async {
final syncNotifier = ref.read(syncProvider.notifier);
final syncedItem = syncNotifier.getSyncedItem(series);
final syncedItem = await syncNotifier.getSyncedItem(series);
if (syncedItem == null) return;
final seriesModel = syncedItem.createItemModel(ref) as SeriesModel;
final allChildren = syncedItem
.getNestedChildren(ref)
.map(
(e) => e.createItemModel(ref),
)
.nonNulls
.toList();
state = seriesModel.copyWith(
availableEpisodes: allChildren.whereType<EpisodeModel>().toList(),
seasons: allChildren.whereType<SeasonModel>().toList(),
);
final seriesModel = syncedItem.itemModel as SeriesModel;
final allChildren = (await syncedItem.getNestedChildren(ref)).map((e) => e.itemModel).toList();
if (mounted) {
state = seriesModel.copyWith(
availableEpisodes: allChildren.whereType<EpisodeModel>().toList(),
seasons: allChildren.whereType<SeasonModel>().toList(),
);
}
return;
}

View file

@ -536,7 +536,10 @@ class JellyService {
return api.showsSeriesIdEpisodesGet(
seriesId: seriesId,
userId: account?.id,
fields: fields,
fields: [
...?fields,
ItemFields.parentid,
],
isMissing: isMissing,
limit: limit,
sortBy: sortBy,
@ -694,7 +697,10 @@ class JellyService {
seriesId: seriesId,
isMissing: isMissing,
enableUserData: enableUserData,
fields: fields,
fields: [
...?fields,
ItemFields.parentid,
],
);
Future<Response<QueryFilters>> itemsFilters2Get({
@ -903,37 +909,37 @@ class JellyService {
Future<Response<dynamic>> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId);
Future<UserConfiguration?> _updateUserConfiguration(UserConfiguration newUserConfiguration) async {
if (account?.id == null) return null;
Future<UserConfiguration?> _updateUserConfiguration(UserConfiguration newUserConfiguration) async {
if (account?.id == null) return null;
final response = await api.usersConfigurationPost(
userId: account!.id,
body: newUserConfiguration,
);
final response = await api.usersConfigurationPost(
userId: account!.id,
body: newUserConfiguration,
);
if (response.isSuccessful) {
return newUserConfiguration;
if (response.isSuccessful) {
return newUserConfiguration;
}
return null;
}
return null;
}
Future<UserConfiguration?> updateRememberAudioSelections() {
final currentUserConfiguration = account?.userConfiguration;
if (currentUserConfiguration == null) return Future.value(null);
Future<UserConfiguration?> updateRememberAudioSelections() {
final currentUserConfiguration = account?.userConfiguration;
if (currentUserConfiguration == null) return Future.value(null);
final updated = currentUserConfiguration.copyWith(
rememberAudioSelections: !(currentUserConfiguration.rememberAudioSelections ?? false),
);
return _updateUserConfiguration(updated);
}
final updated = currentUserConfiguration.copyWith(
rememberAudioSelections: !(currentUserConfiguration.rememberAudioSelections ?? false),
);
return _updateUserConfiguration(updated);
}
Future<UserConfiguration?> updateRememberSubtitleSelections() {
final current = account?.userConfiguration;
if (current == null) return Future.value(null);
Future<UserConfiguration?> updateRememberSubtitleSelections() {
final current = account?.userConfiguration;
if (current == null) return Future.value(null);
final updated = current.copyWith(
rememberSubtitleSelections: !(current.rememberSubtitleSelections ?? false),
);
return _updateUserConfiguration(updated);
}
final updated = current.copyWith(
rememberSubtitleSelections: !(current.rememberSubtitleSelections ?? false),
);
return _updateUserConfiguration(updated);
}
}

View file

@ -1,7 +1,10 @@
import 'package:flutter/widgets.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/util/localization_helper.dart';
part 'background_download_provider.g.dart';
@ -14,13 +17,7 @@ class BackgroundDownloader extends _$BackgroundDownloader {
..configure(
globalConfig: globalConfig(maxDownloads),
)
..trackTasks()
..configureNotification(
running: const TaskNotification('Downloading', 'file: {filename}'),
complete: const TaskNotification('Download finished', 'file: {filename}'),
paused: const TaskNotification('Download paused', 'file: {filename}'),
progressBar: true,
);
..trackTasks();
}
void setMaxConcurrent(int value) {
@ -29,6 +26,16 @@ class BackgroundDownloader extends _$BackgroundDownloader {
);
}
void updateTranslations(BuildContext context) async {
state.configureNotification(
running: TaskNotification(context.localized.notificationDownloadingDownloading, '{filename}\n{networkSpeed}'),
complete: TaskNotification(context.localized.notificationDownloadingFinished, '{filename}'),
paused: TaskNotification(context.localized.notificationDownloadingPaused, '{filename}'),
error: TaskNotification(context.localized.notificationDownloadingError, '{filename}'),
progressBar: true,
);
}
(String, dynamic) globalConfig(int value) => value == 0
? ("", "")
: (

View file

@ -7,7 +7,7 @@ part of 'background_download_provider.dart';
// **************************************************************************
String _$backgroundDownloaderHash() =>
r'dc27f708fc2f1695d37afcb99f8814bc024037af';
r'9d866549ed7632e855ba30de2765368960889cff';
/// See also [BackgroundDownloader].
@ProviderFor(BackgroundDownloader)

View file

@ -1,40 +1,41 @@
import 'package:isar/isar.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/syncing/download_stream.dart';
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/sync_provider.dart';
part 'sync_provider_helpers.g.dart';
@riverpod
class SyncChildren extends _$SyncChildren {
@override
List<SyncedItem> build(SyncedItem arg) {
final syncedItemIsar = ref.watch(syncProvider.notifier).isar;
final allChildren = <SyncedItem>[];
List<SyncedItem> toProcess = [arg];
while (toProcess.isNotEmpty) {
final currentLevel = toProcess.map(
(parent) {
final children = syncedItemIsar?.iSyncedItems.where().parentIdEqualTo(parent.id).sortBySortName().findAll();
return children?.map((e) => SyncedItem.fromIsar(e, ref.read(syncProvider.notifier).syncPath ?? "")) ??
<SyncedItem>[];
},
);
allChildren.addAll(currentLevel.expand((list) => list));
toProcess = currentLevel.expand((list) => list).toList();
}
return allChildren;
Stream<SyncedItem?> syncedItem(Ref ref, ItemBaseModel? item) {
final id = item?.id;
if (id == null || id.isEmpty) {
return Stream.value(null);
}
return ref.watch(syncProvider.notifier).db.getItem(id).watchSingleOrNull();
}
@riverpod
class SyncedChildren extends _$SyncedChildren {
@override
FutureOr<List<SyncedItem>> build(SyncedItem item) => ref.read(syncProvider.notifier).getChildren(item);
}
@riverpod
class SyncedNestedChildren extends _$SyncedNestedChildren {
@override
FutureOr<List<SyncedItem>> build(SyncedItem item) => ref.read(syncProvider.notifier).getNestedChildren(item);
}
@riverpod
class SyncDownloadStatus extends _$SyncDownloadStatus {
@override
DownloadStream? build(SyncedItem arg) {
final nestedChildren = ref.watch(syncChildrenProvider(arg));
DownloadStream? build(SyncedItem arg, List<SyncedItem> children) {
final nestedChildren = children;
ref.watch(downloadTasksProvider(arg.id));
for (var element in nestedChildren) {
@ -45,60 +46,53 @@ class SyncDownloadStatus extends _$SyncDownloadStatus {
int downloadCount = 0;
double fullProgress = mainStream.hasDownload ? mainStream.progress : 0.0;
int fullySyncedChildren = 0;
for (var i = 0; i < nestedChildren.length; i++) {
final childItem = nestedChildren[i];
final downloadStream = ref.read(downloadTasksProvider(childItem.id));
if (childItem.videoFile.existsSync()) {
fullySyncedChildren++;
}
if (downloadStream.hasDownload) {
downloadCount++;
fullProgress += downloadStream.progress;
mainStream = mainStream.copyWith(status: downloadStream.status);
mainStream = mainStream.copyWith(
status: mainStream.status != TaskStatus.running ? downloadStream.status : mainStream.status,
);
}
}
int syncAbleChildren = nestedChildren.where((element) => element.hasVideoFile).length;
var fullySynced = nestedChildren.isNotEmpty ? fullySyncedChildren == syncAbleChildren : arg.videoFile.existsSync();
return mainStream.copyWith(
status: fullySynced ? TaskStatus.complete : mainStream.status,
progress: fullProgress / downloadCount.clamp(1, double.infinity).toInt(),
);
}
}
@riverpod
class SyncStatuses extends _$SyncStatuses {
@override
FutureOr<SyncStatus> build(SyncedItem arg) async {
final nestedChildren = ref.watch(syncChildrenProvider(arg));
ref.watch(downloadTasksProvider(arg.id));
for (var element in nestedChildren) {
ref.watch(downloadTasksProvider(element.id));
}
for (var i = 0; i < nestedChildren.length; i++) {
final item = nestedChildren[i];
if (item.hasVideoFile && !await item.videoFile.exists()) {
return SyncStatus.partially;
}
}
if (arg.hasVideoFile && !await arg.videoFile.exists()) {
return SyncStatus.partially;
}
return SyncStatus.complete;
}
}
@riverpod
class SyncSize extends _$SyncSize {
@override
int? build(SyncedItem arg) {
final nestedChildren = ref.watch(syncChildrenProvider(arg));
int? build(SyncedItem arg, List<SyncedItem>? children) {
final nestedChildren = children;
ref.watch(downloadTasksProvider(arg.id));
for (var element in nestedChildren) {
ref.watch(downloadTasksProvider(element.id));
}
int size = arg.fileSize ?? 0;
for (var element in nestedChildren) {
size += element.fileSize ?? 0;
if (nestedChildren != null) {
for (var element in nestedChildren) {
ref.watch(downloadTasksProvider(element.id));
}
for (var element in nestedChildren) {
if (element.videoFile.existsSync()) {
size += element.fileSize ?? 0;
}
}
}
return size;
}
}

View file

@ -6,7 +6,7 @@ part of 'sync_provider_helpers.dart';
// RiverpodGenerator
// **************************************************************************
String _$syncChildrenHash() => r'f6fdb1aa36d6655976baa5fbe0d8a6b812d7e95b';
String _$syncedItemHash() => r'7b1178ba78529ebf65425aa4cb8b239a28c7914b';
/// Copied from Dart SDK
class _SystemHash {
@ -29,39 +29,30 @@ class _SystemHash {
}
}
abstract class _$SyncChildren
extends BuildlessAutoDisposeNotifier<List<SyncedItem>> {
late final SyncedItem arg;
/// See also [syncedItem].
@ProviderFor(syncedItem)
const syncedItemProvider = SyncedItemFamily();
List<SyncedItem> build(
SyncedItem arg,
);
}
/// See also [syncedItem].
class SyncedItemFamily extends Family<AsyncValue<SyncedItem?>> {
/// See also [syncedItem].
const SyncedItemFamily();
/// See also [SyncChildren].
@ProviderFor(SyncChildren)
const syncChildrenProvider = SyncChildrenFamily();
/// See also [SyncChildren].
class SyncChildrenFamily extends Family<List<SyncedItem>> {
/// See also [SyncChildren].
const SyncChildrenFamily();
/// See also [SyncChildren].
SyncChildrenProvider call(
SyncedItem arg,
/// See also [syncedItem].
SyncedItemProvider call(
ItemBaseModel? item,
) {
return SyncChildrenProvider(
arg,
return SyncedItemProvider(
item,
);
}
@override
SyncChildrenProvider getProviderOverride(
covariant SyncChildrenProvider provider,
SyncedItemProvider getProviderOverride(
covariant SyncedItemProvider provider,
) {
return call(
provider.arg,
provider.item,
);
}
@ -77,81 +68,75 @@ class SyncChildrenFamily extends Family<List<SyncedItem>> {
_allTransitiveDependencies;
@override
String? get name => r'syncChildrenProvider';
String? get name => r'syncedItemProvider';
}
/// See also [SyncChildren].
class SyncChildrenProvider
extends AutoDisposeNotifierProviderImpl<SyncChildren, List<SyncedItem>> {
/// See also [SyncChildren].
SyncChildrenProvider(
SyncedItem arg,
/// See also [syncedItem].
class SyncedItemProvider extends AutoDisposeStreamProvider<SyncedItem?> {
/// See also [syncedItem].
SyncedItemProvider(
ItemBaseModel? item,
) : this._internal(
() => SyncChildren()..arg = arg,
from: syncChildrenProvider,
name: r'syncChildrenProvider',
(ref) => syncedItem(
ref as SyncedItemRef,
item,
),
from: syncedItemProvider,
name: r'syncedItemProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$syncChildrenHash,
dependencies: SyncChildrenFamily._dependencies,
: _$syncedItemHash,
dependencies: SyncedItemFamily._dependencies,
allTransitiveDependencies:
SyncChildrenFamily._allTransitiveDependencies,
arg: arg,
SyncedItemFamily._allTransitiveDependencies,
item: item,
);
SyncChildrenProvider._internal(
SyncedItemProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
required this.item,
}) : super.internal();
final SyncedItem arg;
final ItemBaseModel? item;
@override
List<SyncedItem> runNotifierBuild(
covariant SyncChildren notifier,
Override overrideWith(
Stream<SyncedItem?> Function(SyncedItemRef provider) create,
) {
return notifier.build(
arg,
);
}
@override
Override overrideWith(SyncChildren Function() create) {
return ProviderOverride(
origin: this,
override: SyncChildrenProvider._internal(
() => create()..arg = arg,
override: SyncedItemProvider._internal(
(ref) => create(ref as SyncedItemRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
item: item,
),
);
}
@override
AutoDisposeNotifierProviderElement<SyncChildren, List<SyncedItem>>
createElement() {
return _SyncChildrenProviderElement(this);
AutoDisposeStreamProviderElement<SyncedItem?> createElement() {
return _SyncedItemProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SyncChildrenProvider && other.arg == arg;
return other is SyncedItemProvider && other.item == item;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
hash = _SystemHash.combine(hash, item.hashCode);
return _SystemHash.finish(hash);
}
@ -159,29 +144,325 @@ class SyncChildrenProvider
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SyncChildrenRef on AutoDisposeNotifierProviderRef<List<SyncedItem>> {
/// The parameter `arg` of this provider.
SyncedItem get arg;
mixin SyncedItemRef on AutoDisposeStreamProviderRef<SyncedItem?> {
/// The parameter `item` of this provider.
ItemBaseModel? get item;
}
class _SyncChildrenProviderElement
extends AutoDisposeNotifierProviderElement<SyncChildren, List<SyncedItem>>
with SyncChildrenRef {
_SyncChildrenProviderElement(super.provider);
class _SyncedItemProviderElement
extends AutoDisposeStreamProviderElement<SyncedItem?> with SyncedItemRef {
_SyncedItemProviderElement(super.provider);
@override
SyncedItem get arg => (origin as SyncChildrenProvider).arg;
ItemBaseModel? get item => (origin as SyncedItemProvider).item;
}
String _$syncedChildrenHash() => r'2b6ce1611750785060df6317ce0ea25e2dc0aeb4';
abstract class _$SyncedChildren
extends BuildlessAutoDisposeAsyncNotifier<List<SyncedItem>> {
late final SyncedItem item;
FutureOr<List<SyncedItem>> build(
SyncedItem item,
);
}
/// See also [SyncedChildren].
@ProviderFor(SyncedChildren)
const syncedChildrenProvider = SyncedChildrenFamily();
/// See also [SyncedChildren].
class SyncedChildrenFamily extends Family<AsyncValue<List<SyncedItem>>> {
/// See also [SyncedChildren].
const SyncedChildrenFamily();
/// See also [SyncedChildren].
SyncedChildrenProvider call(
SyncedItem item,
) {
return SyncedChildrenProvider(
item,
);
}
@override
SyncedChildrenProvider getProviderOverride(
covariant SyncedChildrenProvider provider,
) {
return call(
provider.item,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'syncedChildrenProvider';
}
/// See also [SyncedChildren].
class SyncedChildrenProvider extends AutoDisposeAsyncNotifierProviderImpl<
SyncedChildren, List<SyncedItem>> {
/// See also [SyncedChildren].
SyncedChildrenProvider(
SyncedItem item,
) : this._internal(
() => SyncedChildren()..item = item,
from: syncedChildrenProvider,
name: r'syncedChildrenProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$syncedChildrenHash,
dependencies: SyncedChildrenFamily._dependencies,
allTransitiveDependencies:
SyncedChildrenFamily._allTransitiveDependencies,
item: item,
);
SyncedChildrenProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.item,
}) : super.internal();
final SyncedItem item;
@override
FutureOr<List<SyncedItem>> runNotifierBuild(
covariant SyncedChildren notifier,
) {
return notifier.build(
item,
);
}
@override
Override overrideWith(SyncedChildren Function() create) {
return ProviderOverride(
origin: this,
override: SyncedChildrenProvider._internal(
() => create()..item = item,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
item: item,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<SyncedChildren, List<SyncedItem>>
createElement() {
return _SyncedChildrenProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SyncedChildrenProvider && other.item == item;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, item.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SyncedChildrenRef
on AutoDisposeAsyncNotifierProviderRef<List<SyncedItem>> {
/// The parameter `item` of this provider.
SyncedItem get item;
}
class _SyncedChildrenProviderElement
extends AutoDisposeAsyncNotifierProviderElement<SyncedChildren,
List<SyncedItem>> with SyncedChildrenRef {
_SyncedChildrenProviderElement(super.provider);
@override
SyncedItem get item => (origin as SyncedChildrenProvider).item;
}
String _$syncedNestedChildrenHash() =>
r'ea8dd0e694efa6d6ec0c73d699b5fb3e933f9322';
abstract class _$SyncedNestedChildren
extends BuildlessAutoDisposeAsyncNotifier<List<SyncedItem>> {
late final SyncedItem item;
FutureOr<List<SyncedItem>> build(
SyncedItem item,
);
}
/// See also [SyncedNestedChildren].
@ProviderFor(SyncedNestedChildren)
const syncedNestedChildrenProvider = SyncedNestedChildrenFamily();
/// See also [SyncedNestedChildren].
class SyncedNestedChildrenFamily extends Family<AsyncValue<List<SyncedItem>>> {
/// See also [SyncedNestedChildren].
const SyncedNestedChildrenFamily();
/// See also [SyncedNestedChildren].
SyncedNestedChildrenProvider call(
SyncedItem item,
) {
return SyncedNestedChildrenProvider(
item,
);
}
@override
SyncedNestedChildrenProvider getProviderOverride(
covariant SyncedNestedChildrenProvider provider,
) {
return call(
provider.item,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'syncedNestedChildrenProvider';
}
/// See also [SyncedNestedChildren].
class SyncedNestedChildrenProvider extends AutoDisposeAsyncNotifierProviderImpl<
SyncedNestedChildren, List<SyncedItem>> {
/// See also [SyncedNestedChildren].
SyncedNestedChildrenProvider(
SyncedItem item,
) : this._internal(
() => SyncedNestedChildren()..item = item,
from: syncedNestedChildrenProvider,
name: r'syncedNestedChildrenProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$syncedNestedChildrenHash,
dependencies: SyncedNestedChildrenFamily._dependencies,
allTransitiveDependencies:
SyncedNestedChildrenFamily._allTransitiveDependencies,
item: item,
);
SyncedNestedChildrenProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.item,
}) : super.internal();
final SyncedItem item;
@override
FutureOr<List<SyncedItem>> runNotifierBuild(
covariant SyncedNestedChildren notifier,
) {
return notifier.build(
item,
);
}
@override
Override overrideWith(SyncedNestedChildren Function() create) {
return ProviderOverride(
origin: this,
override: SyncedNestedChildrenProvider._internal(
() => create()..item = item,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
item: item,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<SyncedNestedChildren,
List<SyncedItem>> createElement() {
return _SyncedNestedChildrenProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SyncedNestedChildrenProvider && other.item == item;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, item.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SyncedNestedChildrenRef
on AutoDisposeAsyncNotifierProviderRef<List<SyncedItem>> {
/// The parameter `item` of this provider.
SyncedItem get item;
}
class _SyncedNestedChildrenProviderElement
extends AutoDisposeAsyncNotifierProviderElement<SyncedNestedChildren,
List<SyncedItem>> with SyncedNestedChildrenRef {
_SyncedNestedChildrenProviderElement(super.provider);
@override
SyncedItem get item => (origin as SyncedNestedChildrenProvider).item;
}
String _$syncDownloadStatusHash() =>
r'5a0f8537a977c52e6083bd84265631ea5d160637';
r'6ee039e094f1e007ebaeb20ae63430be829cdeb7';
abstract class _$SyncDownloadStatus
extends BuildlessAutoDisposeNotifier<DownloadStream?> {
late final SyncedItem arg;
late final List<SyncedItem> children;
DownloadStream? build(
SyncedItem arg,
List<SyncedItem> children,
);
}
@ -197,9 +478,11 @@ class SyncDownloadStatusFamily extends Family<DownloadStream?> {
/// See also [SyncDownloadStatus].
SyncDownloadStatusProvider call(
SyncedItem arg,
List<SyncedItem> children,
) {
return SyncDownloadStatusProvider(
arg,
children,
);
}
@ -209,6 +492,7 @@ class SyncDownloadStatusFamily extends Family<DownloadStream?> {
) {
return call(
provider.arg,
provider.children,
);
}
@ -233,8 +517,11 @@ class SyncDownloadStatusProvider extends AutoDisposeNotifierProviderImpl<
/// See also [SyncDownloadStatus].
SyncDownloadStatusProvider(
SyncedItem arg,
List<SyncedItem> children,
) : this._internal(
() => SyncDownloadStatus()..arg = arg,
() => SyncDownloadStatus()
..arg = arg
..children = children,
from: syncDownloadStatusProvider,
name: r'syncDownloadStatusProvider',
debugGetCreateSourceHash:
@ -245,6 +532,7 @@ class SyncDownloadStatusProvider extends AutoDisposeNotifierProviderImpl<
allTransitiveDependencies:
SyncDownloadStatusFamily._allTransitiveDependencies,
arg: arg,
children: children,
);
SyncDownloadStatusProvider._internal(
@ -255,9 +543,11 @@ class SyncDownloadStatusProvider extends AutoDisposeNotifierProviderImpl<
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
required this.children,
}) : super.internal();
final SyncedItem arg;
final List<SyncedItem> children;
@override
DownloadStream? runNotifierBuild(
@ -265,6 +555,7 @@ class SyncDownloadStatusProvider extends AutoDisposeNotifierProviderImpl<
) {
return notifier.build(
arg,
children,
);
}
@ -273,13 +564,16 @@ class SyncDownloadStatusProvider extends AutoDisposeNotifierProviderImpl<
return ProviderOverride(
origin: this,
override: SyncDownloadStatusProvider._internal(
() => create()..arg = arg,
() => create()
..arg = arg
..children = children,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
children: children,
),
);
}
@ -292,13 +586,16 @@ class SyncDownloadStatusProvider extends AutoDisposeNotifierProviderImpl<
@override
bool operator ==(Object other) {
return other is SyncDownloadStatusProvider && other.arg == arg;
return other is SyncDownloadStatusProvider &&
other.arg == arg &&
other.children == children;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
hash = _SystemHash.combine(hash, children.hashCode);
return _SystemHash.finish(hash);
}
@ -309,6 +606,9 @@ class SyncDownloadStatusProvider extends AutoDisposeNotifierProviderImpl<
mixin SyncDownloadStatusRef on AutoDisposeNotifierProviderRef<DownloadStream?> {
/// The parameter `arg` of this provider.
SyncedItem get arg;
/// The parameter `children` of this provider.
List<SyncedItem> get children;
}
class _SyncDownloadStatusProviderElement
@ -318,161 +618,20 @@ class _SyncDownloadStatusProviderElement
@override
SyncedItem get arg => (origin as SyncDownloadStatusProvider).arg;
@override
List<SyncedItem> get children =>
(origin as SyncDownloadStatusProvider).children;
}
String _$syncStatusesHash() => r'f05ee53368d1de130714bba09132e08aba15bc44';
abstract class _$SyncStatuses
extends BuildlessAutoDisposeAsyncNotifier<SyncStatus> {
late final SyncedItem arg;
FutureOr<SyncStatus> build(
SyncedItem arg,
);
}
/// See also [SyncStatuses].
@ProviderFor(SyncStatuses)
const syncStatusesProvider = SyncStatusesFamily();
/// See also [SyncStatuses].
class SyncStatusesFamily extends Family<AsyncValue<SyncStatus>> {
/// See also [SyncStatuses].
const SyncStatusesFamily();
/// See also [SyncStatuses].
SyncStatusesProvider call(
SyncedItem arg,
) {
return SyncStatusesProvider(
arg,
);
}
@override
SyncStatusesProvider getProviderOverride(
covariant SyncStatusesProvider provider,
) {
return call(
provider.arg,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'syncStatusesProvider';
}
/// See also [SyncStatuses].
class SyncStatusesProvider
extends AutoDisposeAsyncNotifierProviderImpl<SyncStatuses, SyncStatus> {
/// See also [SyncStatuses].
SyncStatusesProvider(
SyncedItem arg,
) : this._internal(
() => SyncStatuses()..arg = arg,
from: syncStatusesProvider,
name: r'syncStatusesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$syncStatusesHash,
dependencies: SyncStatusesFamily._dependencies,
allTransitiveDependencies:
SyncStatusesFamily._allTransitiveDependencies,
arg: arg,
);
SyncStatusesProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
}) : super.internal();
final SyncedItem arg;
@override
FutureOr<SyncStatus> runNotifierBuild(
covariant SyncStatuses notifier,
) {
return notifier.build(
arg,
);
}
@override
Override overrideWith(SyncStatuses Function() create) {
return ProviderOverride(
origin: this,
override: SyncStatusesProvider._internal(
() => create()..arg = arg,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<SyncStatuses, SyncStatus>
createElement() {
return _SyncStatusesProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SyncStatusesProvider && other.arg == arg;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SyncStatusesRef on AutoDisposeAsyncNotifierProviderRef<SyncStatus> {
/// The parameter `arg` of this provider.
SyncedItem get arg;
}
class _SyncStatusesProviderElement
extends AutoDisposeAsyncNotifierProviderElement<SyncStatuses, SyncStatus>
with SyncStatusesRef {
_SyncStatusesProviderElement(super.provider);
@override
SyncedItem get arg => (origin as SyncStatusesProvider).arg;
}
String _$syncSizeHash() => r'138702f2dd69ab28d142bab67ab4a497bb24f252';
String _$syncSizeHash() => r'eeb6ab8dc1fdf5696c06e53f04a0e54ad68c6748';
abstract class _$SyncSize extends BuildlessAutoDisposeNotifier<int?> {
late final SyncedItem arg;
late final List<SyncedItem>? children;
int? build(
SyncedItem arg,
List<SyncedItem>? children,
);
}
@ -488,9 +647,11 @@ class SyncSizeFamily extends Family<int?> {
/// See also [SyncSize].
SyncSizeProvider call(
SyncedItem arg,
List<SyncedItem>? children,
) {
return SyncSizeProvider(
arg,
children,
);
}
@ -500,6 +661,7 @@ class SyncSizeFamily extends Family<int?> {
) {
return call(
provider.arg,
provider.children,
);
}
@ -523,8 +685,11 @@ class SyncSizeProvider extends AutoDisposeNotifierProviderImpl<SyncSize, int?> {
/// See also [SyncSize].
SyncSizeProvider(
SyncedItem arg,
List<SyncedItem>? children,
) : this._internal(
() => SyncSize()..arg = arg,
() => SyncSize()
..arg = arg
..children = children,
from: syncSizeProvider,
name: r'syncSizeProvider',
debugGetCreateSourceHash:
@ -534,6 +699,7 @@ class SyncSizeProvider extends AutoDisposeNotifierProviderImpl<SyncSize, int?> {
dependencies: SyncSizeFamily._dependencies,
allTransitiveDependencies: SyncSizeFamily._allTransitiveDependencies,
arg: arg,
children: children,
);
SyncSizeProvider._internal(
@ -544,9 +710,11 @@ class SyncSizeProvider extends AutoDisposeNotifierProviderImpl<SyncSize, int?> {
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
required this.children,
}) : super.internal();
final SyncedItem arg;
final List<SyncedItem>? children;
@override
int? runNotifierBuild(
@ -554,6 +722,7 @@ class SyncSizeProvider extends AutoDisposeNotifierProviderImpl<SyncSize, int?> {
) {
return notifier.build(
arg,
children,
);
}
@ -562,13 +731,16 @@ class SyncSizeProvider extends AutoDisposeNotifierProviderImpl<SyncSize, int?> {
return ProviderOverride(
origin: this,
override: SyncSizeProvider._internal(
() => create()..arg = arg,
() => create()
..arg = arg
..children = children,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
children: children,
),
);
}
@ -580,13 +752,16 @@ class SyncSizeProvider extends AutoDisposeNotifierProviderImpl<SyncSize, int?> {
@override
bool operator ==(Object other) {
return other is SyncSizeProvider && other.arg == arg;
return other is SyncSizeProvider &&
other.arg == arg &&
other.children == children;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
hash = _SystemHash.combine(hash, children.hashCode);
return _SystemHash.finish(hash);
}
@ -597,6 +772,9 @@ class SyncSizeProvider extends AutoDisposeNotifierProviderImpl<SyncSize, int?> {
mixin SyncSizeRef on AutoDisposeNotifierProviderRef<int?> {
/// The parameter `arg` of this provider.
SyncedItem get arg;
/// The parameter `children` of this provider.
List<SyncedItem>? get children;
}
class _SyncSizeProviderElement
@ -606,6 +784,8 @@ class _SyncSizeProviderElement
@override
SyncedItem get arg => (origin as SyncSizeProvider).arg;
@override
List<SyncedItem>? get children => (origin as SyncSizeProvider).children;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -8,10 +8,10 @@ import 'package:flutter/material.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:drift_db_viewer/drift_db_viewer.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:isar/isar.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
@ -20,12 +20,14 @@ import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/models/items/series_model.dart';
import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/syncing/database_item.dart';
import 'package:fladder/models/syncing/download_stream.dart';
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/models/syncing/sync_settings_model.dart';
import 'package:fladder/models/video_stream_model.dart';
@ -37,16 +39,27 @@ import 'package:fladder/providers/sync/background_download_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/migration/isar_drift_migration.dart';
final syncProvider = StateNotifierProvider<SyncNotifier, SyncSettingsModel>((ref) => throw UnimplementedError());
final downloadTasksProvider = StateProvider.family<DownloadStream, String>((ref, id) => DownloadStream.empty());
class SyncNotifier extends StateNotifier<SyncSettingsModel> {
SyncNotifier(this.ref, this.isar, this.mobileDirectory) : super(SyncSettingsModel()) {
SyncNotifier(this.ref, this.mobileDirectory) : super(SyncSettingsModel()) {
_init();
}
final Ref ref;
late final db = AppDatabase(ref);
final Directory mobileDirectory;
final String subPath = "Synced";
void migrateFromIsar() async {
await isarMigration(ref, db, mainDirectory.path);
_initializeQueryStream();
}
void _init() {
cleanupTemporaryFiles();
ref.listen(
@ -54,35 +67,29 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
(previous, next) {
if (previous?.id != next?.id) {
if (next?.id != null) {
_initializeQueryStream(next?.id ?? "");
_initializeQueryStream();
}
}
},
);
final userId = ref.read(userProvider)?.id;
if (userId != null) {
_initializeQueryStream(userId);
}
_initializeQueryStream();
migrateFromIsar();
}
void _initializeQueryStream(String userId) {
void _initializeQueryStream() async {
final userId = ref.read(userProvider)?.id;
if (userId == null) return;
_subscription?.cancel();
final queryStream = getParentSyncItems
?.userIdEqualTo(userId)
.watch()
.asyncMap((event) => event.map((e) => SyncedItem.fromIsar(e, syncPath ?? "")).toList());
final queryStream = db.getParentItems.watch();
final initItems = getParentSyncItems
?.userIdEqualTo(userId)
.findAll()
.mapIndexed((index, element) => SyncedItem.fromIsar(element, syncPath ?? ""))
.toList();
final initItems = await db.getParentItems.get();
state = state.copyWith(items: initItems ?? []);
state = state.copyWith(items: initItems);
_subscription = queryStream?.listen((items) {
_subscription = queryStream.listen((items) {
state = state.copyWith(items: items);
});
}
@ -116,15 +123,8 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
}
}
final Ref ref;
final Isar? isar;
final Directory mobileDirectory;
final String subPath = "Synced";
StreamSubscription<List<SyncedItem>>? _subscription;
IsarCollection<String, ISyncedItem>? get syncedItems => isar?.iSyncedItems;
late final JellyService api = ref.read(jellyApiProvider);
String? get _savePath => !kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)
@ -150,9 +150,6 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
String? get syncPath => saveDirectory?.path;
QueryBuilder<ISyncedItem, ISyncedItem, QAfterFilterCondition>? get getParentSyncItems =>
syncedItems?.where().parentIdIsNull();
Future<int> get directorySize async {
if (saveDirectory == null) return 0;
var files = await saveDirectory!.list(recursive: true).toList();
@ -167,59 +164,62 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
}
Future<void> refresh() async {
state = state.copyWith(
items: (await getParentSyncItems?.userIdEqualTo(ref.read(userProvider)?.id).findAllAsync())
?.map((e) => SyncedItem.fromIsar(e, syncPath ?? ""))
.toList() ??
[]);
state = state.copyWith(items: (await db.getParentItems.get()));
}
List<SyncedItem> getNestedChildren(SyncedItem item) {
final allChildren = <SyncedItem>[];
List<SyncedItem> toProcess = [item];
while (toProcess.isNotEmpty) {
final currentLevel = toProcess.map(
(parent) {
final children = syncedItems?.where().parentIdEqualTo(parent.id).sortBySortName().findAll();
return children?.map((e) => SyncedItem.fromIsar(e, ref.read(syncProvider.notifier).syncPath ?? "")) ??
<SyncedItem>[];
},
);
allChildren.addAll(currentLevel.expand((list) => list));
toProcess = currentLevel.expand((list) => list).toList();
}
return allChildren;
}
Future<List<SyncedItem>> getNestedChildren(SyncedItem item) async => db.getNestedChildren(item);
List<SyncedItem> getChildren(SyncedItem item) {
return (syncedItems?.where().parentIdEqualTo(item.id).sortBySortName().findAll())
?.map(
(e) => SyncedItem.fromIsar(e, syncPath ?? ""),
)
.toList() ??
[];
}
Future<List<SyncedItem>> getChildren(SyncedItem root) async => await db.getChildren(root.id).get();
SyncedItem? getSyncedItem(ItemBaseModel? item) {
Future<SyncedItem?> getSyncedItem(ItemBaseModel? item) async {
final id = item?.id;
if (id == null) return null;
final newItem = syncedItems?.get(id);
if (newItem == null) return null;
return SyncedItem.fromIsar(newItem, syncPath ?? "");
return await db.getItem(id).getSingleOrNull();
}
SyncedItem? getParentItem(String id) {
ISyncedItem? newItem = syncedItems?.get(id);
while (newItem?.parentId != null) {
newItem = syncedItems?.get(newItem!.parentId!);
Future<SyncedItem?> getParentItem(String id) async => await db.getParent(id).getSingleOrNull();
Future<SyncedItem> refreshSyncItem(SyncedItem item) async {
List<SyncedItem> itemsToSync = await getNestedChildren(item);
itemsToSync = [item, ...itemsToSync];
SyncedItem parentItem = item;
List<SyncedItem> newItems = [];
for (var i = 0; i < itemsToSync.length; i++) {
final itemToSync = itemsToSync[i];
final itemResponse = await api.usersUserIdItemsItemIdGetBaseItem(
itemId: itemToSync.id,
);
final itemModel = ItemBaseModel.fromBaseDto(itemResponse.bodyOrThrow, ref);
final syncedParent = await db.getItem(itemToSync.parentId ?? "").getSingleOrNull();
SyncedItem newSyncedItem = await _syncItemData(syncedParent, itemModel, itemResponse.bodyOrThrow);
final updatedItem = itemToSync.copyWith(
itemModel: newSyncedItem.createItemModel(ref),
sortName: newSyncedItem.sortName,
syncing: false,
fImages: newSyncedItem.fImages,
fTrickPlayModel: newSyncedItem.fTrickPlayModel,
subtitles: newSyncedItem.subtitles,
userData: UserData.determineLastUserData([item.userData, newSyncedItem.userData]),
);
newItems.add(updatedItem);
if (itemToSync.id == parentItem.id) {
parentItem = updatedItem;
}
}
if (newItem == null) return null;
return SyncedItem.fromIsar(newItem, syncPath ?? "");
}
ItemBaseModel? getItem(SyncedItem? syncedItem) {
if (syncedItem == null) return null;
return syncedItem.createItemModel(ref);
await db.insertMultipleEntries(newItems);
return parentItem;
}
Future<void> addSyncItem(BuildContext? context, ItemBaseModel item) async {
@ -228,7 +228,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
if (saveDirectory == null) {
String? selectedDirectory =
await FilePicker.platform.getDirectoryPath(dialogTitle: context.localized.syncSelectDownloadsFolder);
if (selectedDirectory?.isEmpty == true) {
if (selectedDirectory?.isEmpty == true && context.mounted) {
fladderSnackbar(context, title: context.localized.syncNoFolderSetup);
return;
}
@ -238,22 +238,29 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
fladderSnackbar(context, title: context.localized.syncAddItemForSyncing(item.detailedName(context) ?? "Unknown"));
final newSync = switch (item) {
EpisodeModel episode => await syncSeries(item.parentBaseModel, episode: episode),
SeasonModel season => await syncSeries(item.parentBaseModel, season: season),
SeriesModel series => await syncSeries(series),
MovieModel movie => await syncMovie(movie),
_ => null
};
fladderSnackbar(context,
title: newSync != null
? context.localized.startedSyncingItem(item.detailedName(context) ?? "Unknown")
: context.localized.unableToSyncItem(item.detailedName(context) ?? "Unknown"));
if (context.mounted) {
fladderSnackbar(context,
title: newSync != null
? context.localized.startedSyncingItem(item.detailedName(context) ?? "Unknown")
: context.localized.unableToSyncItem(item.detailedName(context) ?? "Unknown"));
}
return;
}
void viewDatabase(BuildContext context) =>
Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (context) => DriftDbViewer(db)));
Future<bool> removeSync(BuildContext context, SyncedItem? item) async {
try {
if (item == null) return false;
final nestedChildren = getNestedChildren(item);
final nestedChildren = await getNestedChildren(item);
state = state.copyWith(
items: state.items
@ -266,13 +273,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
await ref.read(backgroundDownloaderProvider).cancelTaskWithId(item.taskId!);
}
final deleteFromDatabase = isar?.write((isar) => syncedItems?.deleteAll([...nestedChildren, item]
.map(
(e) => e.id,
)
.toList()));
if (deleteFromDatabase == 0) return false;
await db.deleteAllItems([...nestedChildren, item]);
for (var i = 0; i < nestedChildren.length; i++) {
final element = nestedChildren[i];
@ -367,7 +368,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
if (data == null) return data;
if (!itemPath.existsSync()) return data;
if (data.isEmpty) return data;
final saveDirectory = Directory(path.joinAll([itemPath.path, "Chapters"]));
final saveDirectory = Directory(path.joinAll([itemPath.path, SyncedItem.chaptersPath]));
await saveDirectory.create(recursive: true);
@ -378,7 +379,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
if (response.bodyBytes.isEmpty) return null;
file.writeAsBytesSync(response.bodyBytes);
return event.copyWith(
imageUrl: path.joinAll(["Chapters", fileName]),
imageUrl: path.joinAll([SyncedItem.chaptersPath, fileName]),
);
}).toList();
return saveChapters.nonNulls.toList();
@ -394,12 +395,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
return data?.copyWith(path: fileName);
}
void updateItemSync(SyncedItem syncedItem) =>
isar?.write((isar) => syncedItems?.put(ISyncedItem.fromSynced(syncedItem, syncPath ?? "")));
Future<void> updateItem(SyncedItem syncedItem) async {
isar?.write((isar) => syncedItems?.put(ISyncedItem.fromSynced(syncedItem, syncPath ?? "")));
}
Future<void> updateItem(SyncedItem syncedItem) async => db.insertItem(syncedItem);
Future<SyncedItem> deleteFullSyncFiles(SyncedItem syncedItem, DownloadTask? task) async {
await syncedItem.deleteDatFiles(ref);
@ -415,7 +411,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
return syncedItem;
}
Future<DownloadStream?> syncVideoFile(SyncedItem syncItem, bool skipDownload) async {
Future<DownloadStream?> syncFile(SyncedItem syncItem, bool skipDownload) async {
cleanupTemporaryFiles();
final playbackResponse = await api.itemsItemIdPlaybackInfoPost(
@ -439,6 +435,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
final mediaSegments = (await api.mediaSegmentsGet(id: syncItem.id))?.body;
syncItem = syncItem.copyWith(
fChapters: await saveChapterImages(item?.overview.chapters, directory) ?? [],
subtitles: subtitles,
fTrickPlayModel: trickPlayFile,
mediaSegments: mediaSegments,
@ -447,23 +444,24 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
await updateItem(syncItem);
final currentTask = ref.read(downloadTasksProvider(syncItem.id));
final user = ref.read(userProvider);
final downloadString = path.joinAll([
"${ref.read(userProvider)?.server}",
"Items",
"${syncItem.id}/Download?api_key=${ref.read(userProvider)?.credentials.token}"
]);
if (user == null) return null;
final downloadUrl = path.joinAll([user.server, "Items", syncItem.id, "Download"]);
try {
if (!skipDownload && currentTask.task == null) {
final downloadTask = DownloadTask(
url: Uri.parse(downloadString).toString(),
url: Uri.parse(downloadUrl).toString(),
directory: syncItem.directory.path,
filename: syncItem.videoFileName,
updates: Updates.statusAndProgress,
baseDirectory: BaseDirectory.root,
urlQueryParameters: {"api_key": user.credentials.token},
headers: user.credentials.header(ref),
requiresWiFi: ref.read(clientSettingsProvider.select((value) => value.requireWifi)),
retries: 5,
retries: 3,
allowPause: true,
);
@ -490,7 +488,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
(state) => state.copyWith(status: status),
);
if (status == TaskStatus.complete) {
if (status == TaskStatus.complete || status == TaskStatus.canceled) {
ref.read(downloadTasksProvider(syncItem.id).notifier).update((state) => DownloadStream.empty());
}
},
@ -508,7 +506,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
Future<void> clear() async {
await mainDirectory.delete(recursive: true);
isar?.write((isar) => syncedItems?.clear());
await db.clearDatabase();
state = state.copyWith(items: []);
}
@ -522,6 +520,24 @@ extension SyncNotifierHelpers on SyncNotifier {
Future<SyncedItem> createSyncItem(BaseItemDto response, {SyncedItem? parent}) async {
final ItemBaseModel item = ItemBaseModel.fromBaseDto(response, ref);
final existingSyncedItem = await getSyncedItem(item);
if (existingSyncedItem != null) return existingSyncedItem;
SyncedItem syncItem = await _syncItemData(parent, item, response);
if (parent == null) {
await db.insertItem(syncItem);
}
return syncItem.copyWith(
fileSize: response.mediaSources?.firstOrNull?.size ?? 0,
syncing: false,
videoFileName: response.path?.split('/').lastOrNull ?? "",
);
}
Future<SyncedItem> _syncItemData(SyncedItem? parent, ItemBaseModel item, BaseItemDto response) async {
final Directory? parentDirectory = parent?.directory;
final directory = Directory(path.joinAll([(parentDirectory ?? saveDirectory)?.path ?? "", item.id]));
@ -542,30 +558,7 @@ extension SyncNotifierHelpers on SyncNotifier {
path: directory.path,
userData: item.userData,
);
//Save item if parent so the user is aware.
if (parent == null) {
isar?.write((isar) => syncedItems?.put(ISyncedItem.fromSynced(syncItem, syncPath)));
}
final origChapters = Chapter.chaptersFromInfo(item.id, response.chapters ?? [], ref);
return syncItem.copyWith(
fChapters: await saveChapterImages(origChapters, directory) ?? [],
fileSize: response.mediaSources?.firstOrNull?.size ?? 0,
syncing: false,
videoFileName: response.path?.split('/').lastOrNull ?? "",
);
}
// Need to move the file after downloading on Android
Future<void> moveFile(DownloadTask downloadTask, SyncedItem syncItem) async {
final currentLocation = File(await downloadTask.filePath());
final wantedLocation = syncItem.videoFile;
if (currentLocation.path != wantedLocation.path) {
await currentLocation.copy(wantedLocation.path);
await currentLocation.delete();
}
return syncItem;
}
Future<SyncedItem?> syncMovie(ItemBaseModel item, {bool skipDownload = false}) async {
@ -580,27 +573,28 @@ extension SyncNotifierHelpers on SyncNotifier {
if (!syncItem.directory.existsSync()) return null;
await syncVideoFile(syncItem, skipDownload);
await db.insertItem(syncItem);
isar?.write((isar) => syncedItems?.put(ISyncedItem.fromSynced(syncItem, syncPath)));
await syncFile(syncItem, skipDownload);
return syncItem;
}
Future<SyncedItem?> syncSeries(SeriesModel item, {EpisodeModel? episode}) async {
Future<SyncedItem?> syncSeries(SeriesModel item, {SeasonModel? season, EpisodeModel? episode}) async {
final response = await api.usersUserIdItemsItemIdGetBaseItem(
itemId: item.id,
);
List<SyncedItem> newItems = [];
SyncedItem? itemToDownload;
List<SyncedItem>? itemsToDownload = [];
SyncedItem seriesItem = await createSyncItem(response.bodyOrThrow);
newItems.add(seriesItem);
if (!seriesItem.directory.existsSync()) return null;
final seasonsResponse = await api.showsSeriesIdSeasonsGet(
seriesId: item.id,
isMissing: false,
enableUserData: true,
fields: [
@ -621,14 +615,13 @@ extension SyncNotifierHelpers on SyncNotifier {
ItemFields.chapters,
ItemFields.trickplay,
],
seriesId: item.id,
);
final seasons = seasonsResponse.body?.items ?? [];
for (var i = 0; i < seasons.length; i++) {
final season = seasons[i];
final syncedSeason = await createSyncItem(season, parent: seriesItem);
final newSeason = seasons[i];
final syncedSeason = await createSyncItem(newSeason, parent: seriesItem);
newItems.add(syncedSeason);
final episodesResponse = await api.showsSeriesIdEpisodesGet(
isMissing: false,
@ -651,30 +644,33 @@ extension SyncNotifierHelpers on SyncNotifier {
ItemFields.chapters,
ItemFields.trickplay,
],
seasonId: season.id,
seasonId: newSeason.id,
seriesId: seriesItem.id,
);
final episodes = episodesResponse.body?.items ?? [];
for (var i = 0; i < episodes.length; i++) {
final item = episodes[i];
final newEpisode = await createSyncItem(item, parent: syncedSeason);
final episodeResults = await Future.wait(
episodes.map((ep) async {
final newEpisode = await createSyncItem(ep, parent: syncedSeason);
return (ep, newEpisode);
}),
);
for (final (ep, newEpisode) in episodeResults) {
newItems.add(newEpisode);
if (episode?.id == item.id) {
itemToDownload = newEpisode;
if (episode?.id == ep.id || newSeason.id == season?.id && !await newEpisode.videoFile.exists()) {
itemsToDownload.add(newEpisode);
}
}
}
isar?.write(
(isar) => syncedItems?.putAll(newItems
.map(
(e) => ISyncedItem.fromSynced(e, syncPath ?? ""),
)
.toList()),
);
await db.insertMultipleEntries(newItems);
if (itemToDownload != null) {
await syncVideoFile(itemToDownload, false);
for (var i = 0; i < itemsToDownload.length; i++) {
final item = itemsToDownload[i];
//No need to await file sync happens in the background
syncFile(item, false);
}
return seriesItem;

View file

@ -71,22 +71,26 @@ final AutoRoute _dashboardRoute = CustomRoute(
page: DashboardRoute.page,
transitionsBuilder: TransitionsBuilders.fadeIn,
initial: true,
maintainState: false,
path: 'dashboard',
);
final AutoRoute _favouritesRoute = CustomRoute(
page: FavouritesRoute.page,
transitionsBuilder: TransitionsBuilders.fadeIn,
maintainState: false,
path: 'favourites',
);
final AutoRoute _syncedRoute = CustomRoute(
page: SyncedRoute.page,
transitionsBuilder: TransitionsBuilders.fadeIn,
maintainState: false,
path: 'synced',
);
final AutoRoute _librariesRoute = CustomRoute(
page: LibraryRoute.page,
transitionsBuilder: TransitionsBuilders.fadeIn,
maintainState: false,
path: 'libraries',
);

View file

@ -464,7 +464,7 @@ class SplashRouteArgs {
class SyncedRoute extends _i17.PageRouteInfo<SyncedRouteArgs> {
SyncedRoute({
_i22.ScrollController? navigationScrollController,
_i22.Key? key,
_i19.Key? key,
List<_i17.PageRouteInfo>? children,
}) : super(
SyncedRoute.name,
@ -498,7 +498,7 @@ class SyncedRouteArgs {
final _i22.ScrollController? navigationScrollController;
final _i22.Key? key;
final _i19.Key? key;
@override
String toString() {

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/book_model.dart';
import 'package:fladder/models/items/images_models.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
@ -91,7 +92,7 @@ class HomeScreen extends ConsumerWidget {
action: () => e.navigate(context),
);
case HomeTabs.sync:
if (canDownload) {
if (canDownload && !kIsWeb) {
return DestinationModel(
label: context.localized.navigationSync,
icon: Icon(e.icon),

View file

@ -6,7 +6,10 @@ import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/syncing/sync_button.dart';
import 'package:fladder/screens/syncing/sync_item_details.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
@ -196,6 +199,19 @@ class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
mainAxisSize: MainAxisSize.min,
children: [
if (widget.item != null) ...[
ref.watch(syncedItemProvider(widget.item)).when(
error: (error, stackTrace) => const SizedBox.shrink(),
data: (syncedItem) {
if (syncedItem == null) {
return const SizedBox.shrink();
}
return IconButton(
onPressed: () => showSyncItemDetails(context, syncedItem, ref),
icon: SyncButton(item: widget.item!, syncedItem: syncedItem),
);
},
loading: () => const SizedBox.shrink(),
),
Builder(
builder: (context) {
final newActions = widget.actions?.call(context);

View file

@ -10,6 +10,7 @@ void fladderSnackbar(
bool showCloseButton = false,
Duration duration = const Duration(seconds: 3),
}) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
title,

View file

@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/screens/details_screens/components/media_stream_information.dart';
import 'package:fladder/screens/shared/media/episode_posters.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
@ -31,7 +30,7 @@ class NextUpEpisode extends ConsumerWidget {
Opacity(
opacity: 0.75,
child: SelectableText(
"${context.localized.season(1)} ${nextEpisode.season} - ${context.localized.episode(1)} ${nextEpisode.episode}",
nextEpisode.seasonEpisodeLabelFull(context),
style: Theme.of(context).textTheme.titleMedium,
),
),
@ -42,13 +41,11 @@ class NextUpEpisode extends ConsumerWidget {
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final syncedItem = ref.read(syncProvider.notifier).getSyncedItem(nextEpisode);
if (constraints.maxWidth < 550) {
return Column(
children: [
EpisodePoster(
episode: nextEpisode,
syncedItem: syncedItem,
showLabel: false,
onTap: () => nextEpisode.navigateTo(context),
actions: const [],
@ -71,7 +68,6 @@ class NextUpEpisode extends ConsumerWidget {
maxWidth: MediaQuery.of(context).size.width / 2),
child: EpisodePoster(
episode: nextEpisode,
syncedItem: syncedItem,
showLabel: false,
onTap: () => nextEpisode.navigateTo(context),
actions: const [],

View file

@ -1,16 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/screens/shared/media/episode_posters.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/humanize_duration.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/util/humanize_duration.dart';
enum EpisodeDetailsViewType {
list(icon: IconsaxPlusBold.grid_6),
@ -48,14 +48,12 @@ class EpisodeDetailsList extends ConsumerWidget {
itemCount: episodes.length,
itemBuilder: (context, index) {
final episode = episodes[index];
final syncedItem = ref.watch(syncProvider.notifier).getSyncedItem(episode);
List<Widget> children = [
Flexible(
flex: 1,
child: EpisodePoster(
episode: episode,
showLabel: false,
syncedItem: syncedItem,
actions: episode.generateActions(context, ref),
onTap: () => episode.navigateTo(context),
isCurrentEpisode: false,

View file

@ -3,10 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/syncing/sync_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
@ -88,11 +86,9 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
itemBuilder: (context, index) {
final episode = episodes[index];
final isCurrentEpisode = index == indexOfCurrent;
final syncedItem = ref.watch(syncProvider.notifier).getSyncedItem(episode);
return EpisodePoster(
episode: episode,
blur: allPlayed ? false : indexOfCurrent < index,
syncedItem: syncedItem,
onTap: widget.onEpisodeTap != null
? () {
widget.onEpisodeTap?.call(
@ -130,7 +126,6 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
class EpisodePoster extends ConsumerWidget {
final EpisodeModel episode;
final SyncedItem? syncedItem;
final bool showLabel;
final Function()? onTap;
final Function()? onLongPress;
@ -141,7 +136,6 @@ class EpisodePoster extends ConsumerWidget {
const EpisodePoster({
super.key,
required this.episode,
this.syncedItem,
this.showLabel = true,
this.onTap,
this.onLongPress,
@ -156,7 +150,6 @@ class EpisodePoster extends ConsumerWidget {
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.local_movies_outlined),
);
final SyncedItem? iSyncedItem = syncedItem;
bool episodeAvailable = episode.status == EpisodeStatus.available;
return AspectRatio(
aspectRatio: 1.76,
@ -203,15 +196,18 @@ class EpisodePoster extends ConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (iSyncedItem != null)
Consumer(builder: (context, ref, child) {
final SyncStatus syncStatus =
ref.watch(syncStatusesProvider(iSyncedItem)).value ?? SyncStatus.partially;
return StatusCard(
color: syncStatus.color,
child: SyncButton(item: episode, syncedItem: syncedItem),
);
}),
ref.watch(syncedItemProvider(episode)).when(
error: (error, stackTrace) => const SizedBox.shrink(),
data: (syncedItem) {
if (syncedItem == null) {
return const SizedBox.shrink();
}
return StatusCard(
child: SyncButton(item: episode, syncedItem: syncedItem),
);
},
loading: () => const SizedBox.shrink(),
),
if (episode.userData.isFavourite)
const StatusCard(
color: Colors.red,
@ -259,7 +255,7 @@ class EpisodePoster extends ConsumerWidget {
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: "Options",
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,

View file

@ -19,6 +19,7 @@ class PosterWidget extends ConsumerWidget {
final int maxLines;
final double? aspectRatio;
final bool inlineTitle;
final bool underTitle;
final Set<ItemActions> excludeActions;
final List<ItemAction> otherActions;
final Function(String id, UserData? newData)? onUserDataChanged;
@ -33,6 +34,7 @@ class PosterWidget extends ConsumerWidget {
this.heroTag,
this.aspectRatio,
this.inlineTitle = false,
this.underTitle = true,
this.excludeActions = const {},
this.otherActions = const [],
this.onUserDataChanged,
@ -64,7 +66,7 @@ class PosterWidget extends ConsumerWidget {
onPressed: onPressed,
),
),
if (!inlineTitle)
if (!inlineTitle && underTitle)
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,

View file

@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/syncing/sync_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.dart';
import 'package:fladder/util/fladder_image.dart';
@ -97,30 +99,48 @@ class SeasonPoster extends ConsumerWidget {
alignment: Alignment.topLeft,
child: placeHolder(season.name),
),
if (season.userData.unPlayedItemCount != 0)
Align(
alignment: Alignment.topRight,
child: StatusCard(
color: Theme.of(context).colorScheme.primary,
useFittedBox: true,
child: Center(
child: Text(
season.userData.unPlayedItemCount.toString(),
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
Align(
alignment: Alignment.topRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ref.watch(syncedItemProvider(season)).when(
error: (error, stackTrace) => const SizedBox.shrink(),
data: (syncedItem) {
if (syncedItem == null) {
return const SizedBox.shrink();
}
return StatusCard(
child: SyncButton(item: season, syncedItem: syncedItem),
);
},
loading: () => const SizedBox.shrink(),
),
if (season.userData.unPlayedItemCount != 0)
StatusCard(
color: Theme.of(context).colorScheme.primary,
useFittedBox: true,
child: Center(
child: Text(
season.userData.unPlayedItemCount.toString(),
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
),
),
)
else
Align(
alignment: Alignment.topRight,
child: StatusCard(
color: Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.check_rounded,
),
),
),
),
),
)
else
Align(
alignment: Alignment.topRight,
child: StatusCard(
color: Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.check_rounded,
),
),
],
),
),
LayoutBuilder(
builder: (context, constraints) {
return FlatButton(
@ -134,7 +154,7 @@ class SeasonPoster extends ConsumerWidget {
items: season.generateActions(context, ref).popupMenuItems(useIcons: true));
},
onTap: () => onSeasonPressed?.call(season),
onLongPress: AdaptiveLayout.of(context).inputDevice != InputDevice.touch
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? () {
showBottomSheetPill(
context: context,

View file

@ -1,71 +1,51 @@
import 'package:flutter/material.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/screens/shared/default_alert_dialog.dart';
import 'package:fladder/screens/syncing/sync_item_details.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SyncButton extends ConsumerStatefulWidget {
class SyncButton extends ConsumerWidget {
final ItemBaseModel item;
final SyncedItem? syncedItem;
final SyncedItem syncedItem;
const SyncButton({required this.item, required this.syncedItem, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _SyncButtonState();
}
Widget build(BuildContext context, WidgetRef ref) {
final nested = ref.watch(syncedNestedChildrenProvider(syncedItem));
return nested.when(
loading: () => const SizedBox.shrink(),
error: (err, stack) => const SizedBox.shrink(),
data: (children) {
final download = ref.watch(syncDownloadStatusProvider(syncedItem, children));
final status = download?.status ?? TaskStatus.notFound;
final progress = download?.progress ?? 0.0;
class _SyncButtonState extends ConsumerState<SyncButton> {
@override
Widget build(BuildContext context) {
final syncedItem = widget.syncedItem;
final status = syncedItem != null ? ref.watch(syncStatusesProvider(syncedItem)).value : null;
final progress = syncedItem != null ? ref.watch(syncDownloadStatusProvider(syncedItem)) : null;
return Stack(
alignment: Alignment.center,
children: [
InkWell(
onTap: syncedItem != null
? () => showSyncItemDetails(context, syncedItem, ref)
: () => showDefaultActionDialog(
context,
'Sync ${widget.item.detailedName}?',
null,
(context) async {
await ref.read(syncProvider.notifier).addSyncItem(context, widget.item);
Navigator.of(context).pop();
},
"Sync",
(context) => Navigator.of(context).pop(),
"Cancel",
),
child: Icon(
syncedItem != null
? status == SyncStatus.partially
? (progress?.progress ?? 0) > 0
? IconsaxPlusLinear.arrow_down
: IconsaxPlusLinear.more_circle
: IconsaxPlusLinear.tick_circle
: IconsaxPlusLinear.arrow_down_2,
color: status?.color,
size: (progress?.progress ?? 0) > 0 ? 16 : null,
),
),
if ((progress?.progress ?? 0) > 0)
IgnorePointer(
child: SizedBox.fromSize(
return Stack(
alignment: Alignment.center,
children: [
Icon(
status == TaskStatus.notFound
? (progress > 0 ? IconsaxPlusLinear.arrow_down_1 : IconsaxPlusLinear.more_circle)
: status.icon,
color: status.color(context),
size: status == TaskStatus.running && progress > 0 ? 16 : null,
),
SizedBox.fromSize(
size: const Size.fromRadius(10),
child: CircularProgressIndicator(
strokeCap: StrokeCap.round,
strokeWidth: 2,
color: status?.color,
value: progress?.progress,
strokeWidth: 1.5,
color: status.color(context),
value: status == TaskStatus.running ? progress.clamp(0.0, 1.0) : 0,
),
),
)
],
],
);
},
);
}
}

View file

@ -1,11 +1,11 @@
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/screens/syncing/widgets/synced_season_poster.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/screens/syncing/widgets/synced_season_poster.dart';
import 'widgets/synced_episode_item.dart';
@ -25,39 +25,31 @@ class _ChildSyncWidgetState extends ConsumerState<ChildSyncWidget> {
@override
Widget build(BuildContext context) {
final baseItem = ref.read(syncProvider.notifier).getItem(syncedItem);
final hasFile = syncedItem.videoFile.existsSync();
final baseItem = syncedItem.itemModel;
if (baseItem == null) {
return Container();
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Card(
child: InkWell(
onTap: () {
Navigator.of(context).pop();
baseItem.navigateTo(context);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Flexible(
child: switch (baseItem) {
SeasonModel season => SyncedSeasonPoster(
syncedItem: syncedItem,
season: season,
),
EpisodeModel episode => SyncedEpisodeItem(
episode: episode,
syncedItem: syncedItem,
hasFile: hasFile,
),
_ => Container(),
},
),
],
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Flexible(
child: switch (baseItem) {
SeasonModel season => SyncedSeasonPoster(
syncedItem: syncedItem,
season: season,
),
EpisodeModel episode => SyncedEpisodeItem(
episode: episode,
syncedItem: syncedItem,
),
_ => Container(),
},
),
],
),
),
),

View file

@ -1,13 +1,11 @@
import 'package:flutter/material.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync/background_download_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/screens/shared/adaptive_dialog.dart';
@ -15,26 +13,29 @@ import 'package:fladder/screens/shared/default_alert_dialog.dart';
import 'package:fladder/screens/shared/media/poster_widget.dart';
import 'package:fladder/screens/syncing/sync_child_item.dart';
import 'package:fladder/screens/syncing/sync_widgets.dart';
import 'package:fladder/screens/syncing/widgets/sync_options_button.dart';
import 'package:fladder/screens/syncing/widgets/sync_progress_builder.dart';
import 'package:fladder/screens/syncing/widgets/sync_status_overlay.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/util/size_formatting.dart';
import 'package:fladder/widgets/shared/alert_content.dart';
import 'package:fladder/widgets/shared/icon_button_await.dart';
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
Future<void> showSyncItemDetails(
BuildContext context,
SyncedItem syncItem,
WidgetRef ref,
) {
return showDialogAdaptive(
) async {
await showDialogAdaptive(
context: context,
builder: (context) => SyncItemDetails(
syncItem: syncItem,
),
);
context.refreshData();
}
class SyncItemDetails extends ConsumerStatefulWidget {
@ -50,200 +51,196 @@ class _SyncItemDetailsState extends ConsumerState<SyncItemDetails> {
@override
Widget build(BuildContext context) {
final baseItem = ref.read(syncProvider.notifier).getItem(syncedItem);
final baseItem = syncedItem.itemModel;
final hasFile = syncedItem.videoFile.existsSync();
final syncChildren = ref.read(syncProvider.notifier).getChildren(syncedItem);
final downloadTask = ref.read(downloadTasksProvider(syncedItem.id));
return SyncStatusOverlay(
final downloadTask = ref.watch(downloadTasksProvider(syncedItem.id));
final syncedChildren = ref.watch(syncedChildrenProvider(syncedItem));
final nestedChildren = ref.watch(syncedNestedChildrenProvider(syncedItem));
return PullToRefresh(
refreshOnStart: false,
onRefresh: () async {
final newItem = await ref.read(syncProvider.notifier).refreshSyncItem(syncedItem);
setState(() {
syncedItem = newItem;
});
},
child: SyncStatusOverlay(
syncedItem: syncedItem,
child: ActionContent(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(baseItem?.type.label(context) ?? ""),
)),
Text(
context.localized.navigationSync,
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(IconsaxPlusBold.close_circle),
)
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (baseItem != null) ...{
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: (AdaptiveLayout.poster(context).size *
ref.watch(clientSettingsProvider.select((value) => value.posterSize))) *
0.6,
child: IgnorePointer(
child: PosterWidget(
aspectRatio: 0.7,
poster: baseItem,
inlineTitle: true,
),
),
),
Expanded(
child: SyncProgressBuilder(
item: syncedItem,
builder: (context, combinedStream) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
baseItem.detailedName(context) ?? "",
style: Theme.of(context).textTheme.titleMedium,
),
SyncSubtitle(syncItem: syncedItem),
SyncLabel(
label: context.localized
.totalSize(ref.watch(syncSizeProvider(syncedItem)).byteFormat ?? '--'),
status: ref.watch(syncStatusesProvider(syncedItem)).value ?? SyncStatus.partially,
),
].addInBetween(const SizedBox(height: 8)),
),
),
if (combinedStream?.task != null) ...{
if (combinedStream?.status != TaskStatus.paused)
IconButton(
onPressed: () =>
ref.read(backgroundDownloaderProvider).pause(combinedStream!.task!),
icon: const Icon(IconsaxPlusBold.pause),
),
if (combinedStream?.status == TaskStatus.paused) ...[
IconButton(
onPressed: () =>
ref.read(backgroundDownloaderProvider).resume(combinedStream!.task!),
icon: const Icon(IconsaxPlusBold.play),
),
IconButton(
onPressed: () => ref
.read(syncProvider.notifier)
.deleteFullSyncFiles(syncedItem, combinedStream?.task),
icon: const Icon(IconsaxPlusBold.stop),
),
],
const SizedBox(width: 16)
},
if (combinedStream != null && combinedStream.hasDownload)
SizedBox.fromSize(
size: const Size.fromRadius(35),
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: combinedStream.progress,
strokeWidth: 8,
backgroundColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.5),
strokeCap: StrokeCap.round,
color: combinedStream.status.color(context),
),
Center(child: Text("${((combinedStream.progress) * 100).toStringAsFixed(0)}%"))
],
)),
],
);
},
),
),
if (!hasFile && !downloadTask.hasDownload && syncedItem.hasVideoFile)
IconButtonAwait(
onPressed: () async => await ref.read(syncProvider.notifier).syncVideoFile(syncedItem, false),
icon: const Icon(IconsaxPlusLinear.cloud_change),
)
else if (hasFile)
IconButtonAwait(
color: Theme.of(context).colorScheme.error,
onPressed: () {
showDefaultAlertDialog(
context,
context.localized.syncRemoveDataTitle,
context.localized.syncRemoveDataDesc,
(context) {
ref.read(syncProvider.notifier).deleteFullSyncFiles(syncedItem, downloadTask.task);
Navigator.of(context).pop();
},
context.localized.delete,
(context) => Navigator.of(context).pop(),
context.localized.cancel,
);
},
icon: const Icon(IconsaxPlusLinear.trash),
),
].addInBetween(const SizedBox(width: 16)),
),
},
const Divider(),
if (syncChildren.isNotEmpty == true)
Flexible(
child: ListView(
shrinkWrap: true,
children: [
...syncChildren.map(
(e) => ChildSyncWidget(syncedChild: e),
),
],
child: switch (syncedChildren) {
AsyncValue<List<SyncedItem>>(value: final children) => ActionContent(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(baseItem?.type.label(context) ?? ""),
)),
Text(
context.localized.navigationSync,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
actions: [
if (baseItem is! EpisodeModel)
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
iconColor: Theme.of(context).colorScheme.onErrorContainer,
),
onPressed: () {
showDefaultAlertDialog(
context,
context.localized.syncDeleteItemTitle,
context.localized.syncDeleteItemDesc(baseItem?.detailedName(context) ?? ""),
(context) async {
await ref.read(syncProvider.notifier).removeSync(context, syncedItem);
Navigator.pop(context);
Navigator.pop(context);
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(IconsaxPlusBold.close_circle),
)
],
),
child: ListView(
shrinkWrap: true,
children: [
if (baseItem != null) ...{
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16,
children: [
SizedBox(
height: (AdaptiveLayout.poster(context).size *
ref.watch(clientSettingsProvider.select((value) => value.posterSize))) *
0.6,
child: IgnorePointer(
child: PosterWidget(
aspectRatio: 0.70,
poster: baseItem,
underTitle: false,
),
),
),
Expanded(
child: switch (nestedChildren) {
AsyncValue<List<SyncedItem>>(:final value) => Builder(
builder: (context) {
final nestedChildren = value ?? [];
return SyncProgressBuilder(
item: syncedItem,
children: nestedChildren,
builder: (context, combinedStream) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(
child: Text(
baseItem.detailedName(context) ?? "",
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
Flexible(
child: SyncSubtitle(
syncItem: syncedItem,
children: nestedChildren,
),
),
Flexible(
child: Consumer(
builder: (context, ref, child) => SyncLabel(
label: context.localized.totalSize(ref
.watch(syncSizeProvider(syncedItem, nestedChildren))
.byteFormat ??
'--'),
status: combinedStream?.status ?? TaskStatus.notFound,
),
),
),
if (combinedStream != null && combinedStream.hasDownload == true)
SyncProgressBar(item: syncedItem, task: combinedStream)
],
);
},
);
},
),
},
),
if (syncedItem.hasVideoFile && !hasFile && !downloadTask.hasDownload)
IconButtonAwait(
onPressed: () async => await ref.read(syncProvider.notifier).syncFile(syncedItem, false),
icon: const Icon(IconsaxPlusLinear.cloud_change),
)
else if (hasFile)
IconButtonAwait(
color: Theme.of(context).colorScheme.error,
onPressed: () {
showDefaultAlertDialog(
context,
context.localized.syncRemoveDataTitle,
context.localized.syncRemoveDataDesc,
(context) {
ref.read(syncProvider.notifier).deleteFullSyncFiles(syncedItem, downloadTask.task);
Navigator.of(context).pop();
},
context.localized.delete,
(context) => Navigator.of(context).pop(),
context.localized.cancel,
);
},
icon: const Icon(IconsaxPlusLinear.trash),
),
nestedChildren.when(
data: (data) => SyncOptionsButton(
syncedItem: syncedItem,
children: data,
),
error: (error, stackTrace) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
)
],
),
},
if (children?.isNotEmpty == true) ...[
const Divider(),
...children!.map(
(e) => ChildSyncWidget(syncedChild: e),
),
],
],
),
actions: [
if (syncedItem.parentId == null)
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
iconColor: Theme.of(context).colorScheme.onErrorContainer,
),
onPressed: () {
showDefaultAlertDialog(
context,
context.localized.syncDeleteItemTitle,
context.localized.syncDeleteItemDesc(baseItem?.detailedName(context) ?? ""),
(localContext) async {
await ref.read(syncProvider.notifier).removeSync(context, syncedItem);
Navigator.pop(localContext);
Navigator.pop(context);
},
context.localized.delete,
(context) => Navigator.pop(context),
context.localized.cancel,
);
},
context.localized.delete,
(context) => Navigator.pop(context),
context.localized.cancel,
);
},
child: Text(context.localized.delete),
)
else if (syncedItem.parentId != null)
ElevatedButton(
onPressed: () {
final parentItem = ref.read(syncProvider.notifier).getParentItem(syncedItem.parentId!);
setState(() {
if (parentItem != null) {
syncedItem = parentItem;
}
});
},
child: Text(context.localized.syncOpenParent),
)
],
));
child: Text(context.localized.delete),
)
else if (baseItem?.parentBaseModel != null)
ElevatedButton(
onPressed: () async {
final parentItem = await ref.read(syncProvider.notifier).getSyncedItem(baseItem!.parentBaseModel);
setState(() {
if (parentItem != null) {
syncedItem = parentItem;
}
});
},
child: Text(context.localized.syncOpenParent),
)
],
),
},
),
);
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
@ -12,7 +13,6 @@ import 'package:fladder/screens/syncing/sync_widgets.dart';
import 'package:fladder/screens/syncing/widgets/sync_progress_builder.dart';
import 'package:fladder/screens/syncing/widgets/sync_status_overlay.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/size_formatting.dart';
@ -28,7 +28,7 @@ class SyncListItemState extends ConsumerState<SyncListItem> {
@override
Widget build(BuildContext context) {
final syncedItem = widget.syncedItem;
final baseItem = ref.read(syncProvider.notifier).getItem(syncedItem);
final baseItem = syncedItem.itemModel;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SyncStatusOverlay(
@ -66,82 +66,99 @@ class SyncListItemState extends ConsumerState<SyncListItem> {
context.localized.cancel);
return false;
},
child: LayoutBuilder(builder: (context, constraints) {
return IntrinsicHeight(
child: InkWell(
onTap: () => baseItem?.navigateTo(context),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: 125, maxWidth: constraints.maxWidth * 0.2),
child: Card(
child: AspectRatio(
aspectRatio: baseItem?.primaryRatio ?? 1.0,
child: FladderImage(
image: baseItem?.getPosters?.primary,
fit: BoxFit.cover,
)),
),
),
Expanded(
child: SyncProgressBuilder(
item: syncedItem,
builder: (context, combinedStream) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
baseItem?.detailedName(context) ?? "",
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
Flexible(
child: SyncSubtitle(syncItem: syncedItem),
),
Flexible(
child: SyncLabel(
label: context.localized
.totalSize(ref.watch(syncSizeProvider(syncedItem)).byteFormat ?? '--'),
status: ref.watch(syncStatusesProvider(syncedItem)).value ?? SyncStatus.partially,
),
),
if (combinedStream != null && combinedStream.hasDownload == true)
SyncProgressBar(item: syncedItem, task: combinedStream)
].addInBetween(const SizedBox(height: 4)),
);
},
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Card(
elevation: 0,
shadowColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(baseItem != null ? baseItem.type.label(context) : ""),
)),
IconButton(
onPressed: () => showSyncItemDetails(context, syncedItem, ref),
icon: const Icon(IconsaxPlusLinear.more_square),
child: LayoutBuilder(
builder: (context, constraints) {
return IntrinsicHeight(
child: InkWell(
onTap: () => baseItem?.navigateTo(context),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: 125, maxWidth: constraints.maxWidth * 0.2),
child: Card(
child: AspectRatio(
aspectRatio: baseItem?.primaryRatio ?? 1.0,
child: FladderImage(
image: baseItem?.getPosters?.primary,
fit: BoxFit.cover,
)),
),
],
),
].addInBetween(const SizedBox(width: 16)),
),
Expanded(
child: FutureBuilder(
future: ref.read(syncProvider.notifier).getNestedChildren(syncedItem),
builder: (context, asyncSnapshot) {
final nestedChildren = asyncSnapshot.data ?? [];
return SyncProgressBuilder(
item: syncedItem,
children: nestedChildren,
builder: (context, combinedStream) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(
child: Text(
baseItem?.detailedName(context) ?? "",
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
Flexible(
child: SyncSubtitle(
syncItem: syncedItem,
children: nestedChildren,
),
),
Flexible(
child: Consumer(
builder: (context, ref, child) => SyncLabel(
label: context.localized.totalSize(
ref.watch(syncSizeProvider(syncedItem, nestedChildren)).byteFormat ??
'--'),
status: combinedStream?.status ?? TaskStatus.notFound,
),
),
),
if (combinedStream != null && combinedStream.hasDownload == true)
SyncProgressBar(item: syncedItem, task: combinedStream)
],
);
},
);
},
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Card(
elevation: 0,
shadowColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(baseItem != null ? baseItem.type.label(context) : ""),
)),
IconButton(
onPressed: () => showSyncItemDetails(context, syncedItem, ref),
icon: const Icon(IconsaxPlusLinear.more_square),
),
],
),
],
),
),
),
),
);
}),
);
},
),
),
),
),

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/season_model.dart';
@ -12,28 +12,34 @@ import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/sync/background_download_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
const _cancellableStatuses = {
TaskStatus.canceled,
TaskStatus.failed,
TaskStatus.enqueued,
TaskStatus.waitingToRetry,
};
class SyncLabel extends ConsumerWidget {
final String? label;
final SyncStatus status;
final TaskStatus status;
const SyncLabel({this.label, required this.status, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
decoration: BoxDecoration(
color: status.color.withValues(alpha: 0.15),
color: status.color(context).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Text(
label ?? status.label(context),
label ?? status.name(context),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: status.color,
color: status.color(context),
),
),
),
@ -61,6 +67,7 @@ class SyncProgressBar extends ConsumerWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Flexible(
child: LinearProgressIndicator(
@ -72,23 +79,29 @@ class SyncProgressBar extends ConsumerWidget {
),
Opacity(opacity: 0.75, child: Text("${(downloadProgress * 100).toStringAsFixed(0)}%")),
if (downloadTask != null) ...{
if (downloadStatus != TaskStatus.paused)
if (downloadStatus != TaskStatus.paused && downloadStatus != TaskStatus.enqueued)
IconButton(
onPressed: () => ref.read(backgroundDownloaderProvider).pause(downloadTask),
icon: const Icon(IconsaxPlusBold.pause),
),
if (downloadStatus == TaskStatus.paused) ...[
IconButton(
onPressed: () => ref.read(backgroundDownloaderProvider).resume(downloadTask),
icon: const Icon(IconsaxPlusBold.play),
),
IconButton(
onPressed: () => ref.read(syncProvider.notifier).deleteFullSyncFiles(item, downloadTask),
icon: const Icon(IconsaxPlusBold.stop),
)
],
if (_cancellableStatuses.contains(downloadStatus)) ...[
IconButton(
onPressed: () => ref.read(syncProvider.notifier).deleteFullSyncFiles(item, downloadTask),
icon: const Icon(IconsaxPlusBold.stop),
),
],
},
if (downloadStatus == TaskStatus.paused && downloadTask != null) ...[
IconButton(
onPressed: () => ref.read(backgroundDownloaderProvider).resume(downloadTask),
icon: const Icon(IconsaxPlusBold.play),
),
IconButton(
onPressed: () => ref.read(syncProvider.notifier).deleteFullSyncFiles(item, downloadTask),
icon: const Icon(IconsaxPlusBold.stop),
)
],
].addInBetween(const SizedBox(width: 8)),
],
),
const SizedBox(width: 6),
],
@ -98,41 +111,55 @@ class SyncProgressBar extends ConsumerWidget {
class SyncSubtitle extends ConsumerWidget {
final SyncedItem syncItem;
final List<SyncedItem> children;
const SyncSubtitle({
required this.syncItem,
this.children = const [],
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final baseItem = ref.read(syncProvider.notifier).getItem(syncItem);
final children = syncItem.nestedChildren(ref);
final syncStatus = ref.watch(syncStatusesProvider(syncItem)).value ?? SyncStatus.partially;
final baseItem = syncItem.itemModel;
final syncStatus = ref
.watch(syncDownloadStatusProvider(syncItem, children).select((value) => value?.status ?? TaskStatus.notFound));
return Container(
decoration:
BoxDecoration(color: syncStatus.color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)),
decoration: BoxDecoration(
color: syncStatus.color(context).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)),
child: Material(
color: const Color.fromARGB(0, 208, 130, 130),
textStyle:
Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold, color: syncStatus.color),
textStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.bold, color: syncStatus.color(context)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: switch (baseItem) {
SeriesModel _ => Builder(
SeasonModel _ => Builder(
builder: (context) {
final itemBaseModels = children.map((e) => ref.read(syncProvider.notifier).getItem(e));
final seriesItemsSyncLeft = children.where((element) => element.taskId != null).length;
final seasons = itemBaseModels.whereType<SeasonModel>().length;
final itemBaseModels = children.map((e) => e.itemModel);
final episodes = itemBaseModels.whereType<EpisodeModel>().length;
return Text(
[
"${context.localized.season(seasons)}: $seasons",
"${context.localized.episode(seasons)}: $episodes | ${context.localized.sync}: ${children.where((element) => element.videoFile.existsSync()).length}${seriesItemsSyncLeft > 0 ? " | Syncing: $seriesItemsSyncLeft" : ""}"
"${context.localized.episode(2)}: $episodes | ${context.localized.syncStatusSynced}: ${children.where((element) => element.videoFile.existsSync()).length}"
].join('\n'),
);
},
),
_ => Text(syncStatus.label(context)),
SeriesModel _ => Builder(
builder: (context) {
final itemBaseModels = children.map((e) => e.itemModel);
final seasons = itemBaseModels.whereType<SeasonModel>().length;
final episodes = itemBaseModels.whereType<EpisodeModel>().length;
return Text(
[
"${context.localized.season(2)}: $seasons",
"${context.localized.episode(2)}: $episodes | ${context.localized.syncStatusSynced}: ${children.where((element) => element.videoFile.existsSync()).length}"
].join('\n'),
);
},
),
_ => Text(syncStatus.name(context)),
},
),
),

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
@ -52,6 +53,30 @@ class _SyncedScreenState extends ConsumerState<SyncedScreen> {
)
else
const DefaultSliverTopBadding(),
if (kDebugMode)
SliverToBoxAdapter(
child: Padding(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [
ElevatedButton(
onPressed: () => ref.read(syncProvider.notifier).viewDatabase(context),
child: const Text("View Database"),
),
ElevatedButton(
onPressed: () => ref.read(syncProvider.notifier).db.clearDatabase(),
child: const Text("Clear drift database"),
),
ElevatedButton(
onPressed: () => ref.read(syncProvider.notifier).migrateFromIsar(),
child: const Text("Migrate Isar to Drift"),
),
],
),
),
),
if (items.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(

View file

@ -0,0 +1,200 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/sync/background_download_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/widgets/shared/filled_button_await.dart';
class SyncOptionsButton extends ConsumerWidget {
final SyncedItem syncedItem;
final List<SyncedItem> children;
const SyncOptionsButton({
required this.syncedItem,
required this.children,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton(
itemBuilder: (context) {
final unSyncedChildren = children.where((element) {
final hasDownload = ref.read(syncDownloadStatusProvider(element, []));
return element.hasVideoFile && !element.videoFile.existsSync() && hasDownload?.status == TaskStatus.notFound;
}).toList();
final syncedChildren =
children.where((element) => element.hasVideoFile && element.videoFile.existsSync()).toList();
final syncTasks = children
.map((element) {
final task = ref.read(syncDownloadStatusProvider(element, []));
if (task?.status != TaskStatus.notFound) {
return task;
} else {
return null;
}
})
.nonNulls
.toList();
final runningTasks = syncTasks.where((element) => element.status == TaskStatus.running).toList();
final enqueuedTasks = syncTasks.where((element) => element.status == TaskStatus.enqueued).toList();
final pausedTasks = syncTasks.where((element) => element.status == TaskStatus.paused).toList();
return <PopupMenuEntry>[
PopupMenuItem(
child: Row(
spacing: 12,
children: [
const Icon(IconsaxPlusLinear.refresh_2),
Text(context.localized.refreshMetadata),
],
),
onTap: () => context.refreshData(),
),
if (children.isNotEmpty) ...[
const PopupMenuDivider(),
PopupMenuItem(
enabled: unSyncedChildren.isNotEmpty,
child: Row(
spacing: 12,
children: [
const Icon(IconsaxPlusLinear.cloud_add),
Text(context.localized.syncAllFiles),
],
),
onTap: () async => _syncRemainingItems(context, syncedItem, unSyncedChildren, ref),
),
PopupMenuItem(
enabled: syncedChildren.isNotEmpty,
child: Row(
spacing: 12,
children: [
const Icon(IconsaxPlusLinear.trash),
Text(context.localized.syncDeleteAll),
],
),
onTap: () async => _deleteSyncedItems(context, syncedItem, syncedChildren, ref),
),
const PopupMenuDivider(),
PopupMenuItem(
enabled: pausedTasks.isNotEmpty,
child: Row(
spacing: 12,
children: [
const Icon(IconsaxPlusLinear.play),
Text(context.localized.syncResumeAll),
],
),
onTap: () => ref
.read(backgroundDownloaderProvider)
.resumeAll(tasks: pausedTasks.map((e) => e.task).nonNulls.toList()),
),
PopupMenuItem(
enabled: runningTasks.isNotEmpty,
child: Row(
spacing: 12,
children: [
const Icon(IconsaxPlusLinear.pause),
Text(context.localized.syncPauseAll),
],
),
onTap: () {
ref
.read(backgroundDownloaderProvider)
.pauseAll(tasks: runningTasks.map((e) => e.task).nonNulls.toList());
},
),
PopupMenuItem(
enabled: [...runningTasks, ...pausedTasks, ...enqueuedTasks].isNotEmpty,
child: Row(
spacing: 12,
children: [
const Icon(IconsaxPlusLinear.stop),
Text(context.localized.syncStopAll),
],
),
onTap: () {
ref.read(backgroundDownloaderProvider).cancelAll(
tasks: [...runningTasks, ...pausedTasks, ...enqueuedTasks].map((e) => e.task).nonNulls.toList());
},
),
]
];
},
);
}
}
Future<dynamic> _deleteSyncedItems(
BuildContext context, SyncedItem syncedItem, List<SyncedItem> syncedChildren, WidgetRef ref) {
return showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(context.localized.syncDeleteAllItemsTitle(syncedItem.itemModel?.name ?? "")),
content: Text(
context.localized.syncDeleteAllItemsDesc(syncedItem.itemModel?.name ?? "", syncedChildren.length),
),
scrollable: true,
actions: [
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.cancel)),
FilledButtonAwait(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
iconColor: Theme.of(context).colorScheme.onErrorContainer,
),
onPressed: () async {
final deleteList = syncedChildren.map((e) => ref.read(syncProvider.notifier).deleteFullSyncFiles(e, null));
await Future.wait(deleteList);
Navigator.of(context).pop();
},
child: Text(
context.localized.delete,
),
)
],
),
);
}
Future<dynamic> _syncRemainingItems(
BuildContext context, SyncedItem syncedItem, List<SyncedItem> unSyncedChildren, WidgetRef ref) {
return showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(context.localized.syncAllItemsTitle(syncedItem.itemModel?.name ?? "")),
content: Text(
context.localized.syncAllItemsDesc(
syncedItem.itemModel?.name ?? "",
unSyncedChildren.length,
),
),
scrollable: true,
actions: [
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.cancel)),
FilledButtonAwait(
onPressed: () async {
final syncList = unSyncedChildren.map((e) => ref.read(syncProvider.notifier).syncFile(e, false));
await Future.wait(syncList);
Navigator.of(context).pop();
},
child: Text(
context.localized.sync,
),
)
],
),
);
}

View file

@ -1,17 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/syncing/download_stream.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SyncProgressBuilder extends ConsumerWidget {
final SyncedItem item;
final List<SyncedItem> children;
final Widget Function(BuildContext context, DownloadStream? combinedStream) builder;
const SyncProgressBuilder({required this.item, required this.builder, super.key});
const SyncProgressBuilder({required this.item, this.children = const [], required this.builder, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final syncStatus = ref.watch(syncDownloadStatusProvider(item));
final syncStatus = ref.watch(syncDownloadStatusProvider(item, children));
return builder(context, syncStatus);
}
}

View file

@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/screens/shared/default_alert_dialog.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/media/episode_posters.dart';
import 'package:fladder/screens/syncing/sync_widgets.dart';
import 'package:fladder/util/list_padding.dart';
@ -20,12 +23,10 @@ class SyncedEpisodeItem extends ConsumerStatefulWidget {
super.key,
required this.episode,
required this.syncedItem,
required this.hasFile,
});
final EpisodeModel episode;
final SyncedItem syncedItem;
final bool hasFile;
@override
ConsumerState<SyncedEpisodeItem> createState() => _SyncedEpisodeItemState();
@ -38,87 +39,94 @@ class _SyncedEpisodeItemState extends ConsumerState<SyncedEpisodeItem> {
final downloadTask = ref.watch(downloadTasksProvider(syncedItem.id));
final hasFile = widget.syncedItem.videoFile.existsSync();
return Row(
children: [
IgnorePointer(
child: ConstrainedBox(
return IntrinsicHeight(
child: Row(
children: [
ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.3),
child: SizedBox(
width: 250,
child: EpisodePoster(
episode: widget.episode,
syncedItem: syncedItem,
actions: [],
showLabel: false,
isCurrentEpisode: false,
child: FlatButton(
onTap: () {
widget.episode.navigateTo(context);
return context.maybePop();
},
child: SizedBox(
width: 175,
child: EpisodePoster(
episode: widget.episode,
actions: [],
showLabel: false,
isCurrentEpisode: false,
),
),
),
),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.episode.name,
style: Theme.of(context).textTheme.titleMedium,
),
Opacity(
opacity: 0.75,
child: Text(
widget.episode.seasonEpisodeLabel(context),
style: Theme.of(context).textTheme.bodyLarge,
Expanded(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.episode.name,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
),
if (!widget.hasFile && downloadTask.hasDownload)
Flexible(
child: SyncProgressBar(item: syncedItem, task: downloadTask),
)
else
Flexible(
child: SyncLabel(
label: context.localized.totalSize(ref.watch(syncSizeProvider(syncedItem)).byteFormat ?? '--'),
status: ref.watch(syncStatusesProvider(syncedItem)).value ?? SyncStatus.partially,
Opacity(
opacity: 0.75,
child: Text(
widget.episode.seasonEpisodeLabel(context),
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
),
)
],
),
if (!hasFile && downloadTask.hasDownload)
Flexible(
child: SyncProgressBar(item: syncedItem, task: downloadTask),
)
else
Flexible(
child: SyncLabel(
label:
context.localized.totalSize(ref.watch(syncSizeProvider(syncedItem, [])).byteFormat ?? '--'),
status: ref.watch(syncDownloadStatusProvider(syncedItem, [])
.select((value) => value?.status ?? TaskStatus.notFound)),
),
)
],
),
),
),
if (!hasFile && !downloadTask.hasDownload)
IconButtonAwait(
onPressed: () async => await ref.read(syncProvider.notifier).syncVideoFile(syncedItem, false),
icon: const Icon(IconsaxPlusLinear.cloud_change),
)
else if (hasFile)
IconButtonAwait(
color: Theme.of(context).colorScheme.error,
onPressed: () async {
await showDefaultAlertDialog(
context,
context.localized.syncRemoveDataTitle,
context.localized.syncRemoveDataDesc,
(context) async {
await ref.read(syncProvider.notifier).deleteFullSyncFiles(syncedItem, downloadTask.task);
Navigator.pop(context);
},
context.localized.delete,
(context) => Navigator.pop(context),
context.localized.cancel,
);
},
icon: const Icon(IconsaxPlusLinear.trash),
)
].addInBetween(const SizedBox(width: 16)),
if (!hasFile && !downloadTask.hasDownload)
IconButtonAwait(
onPressed: () async => await ref.read(syncProvider.notifier).syncFile(syncedItem, false),
icon: const Icon(IconsaxPlusLinear.cloud_change),
)
else if (hasFile)
IconButtonAwait(
color: Theme.of(context).colorScheme.error,
onPressed: () async {
await showDefaultAlertDialog(
context,
context.localized.syncRemoveDataTitle,
context.localized.syncRemoveDataDesc,
(context) async {
await ref.read(syncProvider.notifier).deleteFullSyncFiles(syncedItem, downloadTask.task);
Navigator.pop(context);
},
context.localized.delete,
(context) => Navigator.pop(context),
context.localized.cancel,
);
},
icon: const Icon(IconsaxPlusLinear.trash),
)
].addInBetween(const SizedBox(width: 16)),
),
);
}
}

View file

@ -1,13 +1,21 @@
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/syncing/sync_widgets.dart';
import 'package:fladder/screens/syncing/widgets/sync_options_button.dart';
import 'package:fladder/screens/syncing/widgets/sync_progress_builder.dart';
import 'package:fladder/screens/syncing/widgets/synced_episode_item.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/size_formatting.dart';
class SyncedSeasonPoster extends ConsumerStatefulWidget {
const SyncedSeasonPoster({
@ -28,68 +36,96 @@ class _SyncedSeasonPosterState extends ConsumerState<SyncedSeasonPoster> {
@override
Widget build(BuildContext context) {
final season = widget.season;
final children = ref.read(syncProvider.notifier).getChildren(widget.syncedItem);
return Column(
children: [
Row(
children: [
SizedBox(
width: 125,
child: AspectRatio(
aspectRatio: 0.65,
child: Card(
child: FladderImage(
image: season.getPosters?.primary ??
season.parentImages?.backDrop?.firstOrNull ??
season.parentImages?.primary,
final nestedChildren = ref.watch(syncedNestedChildrenProvider(widget.syncedItem));
return nestedChildren.when(
data: (children) => Builder(
builder: (context) {
final syncedItem = widget.syncedItem;
return ExpansionTile(
tilePadding: EdgeInsets.zero,
shape: const Border(),
title: Row(
spacing: 12,
children: [
SizedBox(
width: 75,
child: AspectRatio(
aspectRatio: 0.65,
child: FlatButton(
onTap: () {
season.navigateTo(context);
return context.maybePop();
},
child: Card(
child: FladderImage(
image: season.getPosters?.primary ??
season.parentImages?.backDrop?.firstOrNull ??
season.parentImages?.primary,
),
),
),
),
),
Flexible(
child: SyncProgressBuilder(
item: syncedItem,
children: children,
builder: (context, combinedStream) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(
child: Text(
season.name,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
Flexible(
child: SyncSubtitle(
syncItem: syncedItem,
children: children,
),
),
Flexible(
child: Consumer(
builder: (context, ref, child) => SyncLabel(
label: context.localized
.totalSize(ref.watch(syncSizeProvider(syncedItem, children))?.byteFormat ?? '--'),
status: combinedStream?.status ?? TaskStatus.notFound,
),
),
),
if (combinedStream != null && combinedStream.hasDownload == true)
SyncProgressBar(item: syncedItem, task: combinedStream)
],
);
},
),
),
),
),
Column(
children: [
Text(
season.name,
style: Theme.of(context).textTheme.titleMedium,
)
],
),
const Spacer(),
IconButton(
onPressed: () {
setState(() {
expanded = !expanded;
});
trailing: SyncOptionsButton(syncedItem: syncedItem, children: children),
children: children.map(
(item) {
final baseItem = item.itemModel;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SyncedEpisodeItem(
episode: baseItem as EpisodeModel,
syncedItem: item,
),
);
},
icon: Icon(!expanded ? Icons.keyboard_arrow_down_rounded : Icons.keyboard_arrow_up_rounded),
)
].addPadding(const EdgeInsets.symmetric(horizontal: 6)),
),
AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: expanded && children.isNotEmpty
? ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: <Widget>[
const Divider(),
...children.map(
(item) {
final baseItem = ref.read(syncProvider.notifier).getItem(item);
return IntrinsicHeight(
child: SyncedEpisodeItem(
episode: baseItem as EpisodeModel,
syncedItem: item,
hasFile: item.videoFile.existsSync(),
),
);
},
)
].addPadding(const EdgeInsets.symmetric(vertical: 10)),
)
: Container(),
)
].addPadding(EdgeInsets.only(top: 10, bottom: expanded ? 10 : 0)),
).toList(),
);
},
),
error: (error, stackTrace) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
);
}
}

View file

@ -126,9 +126,10 @@ class FladderTheme {
listTileTheme: ListTileThemeData(
shape: defaultShape,
),
dividerTheme: const DividerThemeData(
dividerTheme: DividerThemeData(
indent: 6,
endIndent: 6,
color: scheme?.onSurface.withAlpha(125),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(

View file

@ -109,8 +109,8 @@ extension ItemBaseModelExtensions on ItemBaseModel {
)) &&
syncAble &&
(canDownload ?? false);
final syncedItem = ref.read(syncProvider.notifier).getSyncedItem(this);
final downloadUrl = ref.read(userProvider.notifier).createDownloadUrl(this);
final syncedItemFuture = ref.read(syncProvider.notifier).getSyncedItem(this);
return [
if (!exclude.contains(ItemActions.play))
if (playAble)
@ -234,18 +234,39 @@ extension ItemBaseModelExtensions on ItemBaseModel {
),
if (!exclude.contains(ItemActions.download) && downloadEnabled) ...[
if (!kIsWeb)
if (syncedItem == null)
ItemActionButton(
icon: const Icon(IconsaxPlusLinear.arrow_down_2),
label: Text(context.localized.sync),
action: () => ref.read(syncProvider.notifier).addSyncItem(context, this),
)
else
ItemActionButton(
icon: IgnorePointer(child: SyncButton(item: this, syncedItem: syncedItem)),
action: () => showSyncItemDetails(context, syncedItem, ref),
label: Text(context.localized.syncDetails),
)
ItemActionButton(
icon: FutureBuilder(
future: syncedItemFuture,
builder: (context, snapshot) {
final syncedItem = snapshot.data;
if (syncedItem != null) {
return IgnorePointer(child: SyncButton(item: this, syncedItem: syncedItem));
}
return const Icon(IconsaxPlusLinear.arrow_down_2);
},
),
label: FutureBuilder(
future: syncedItemFuture,
builder: (context, snapshot) {
final syncedItem = snapshot.data;
if (syncedItem != null) {
return Text(
context.localized.syncDetails,
);
}
return Text(context.localized.sync);
},
),
action: () async {
final syncedItem = await syncedItemFuture;
if (syncedItem != null) {
await showSyncItemDetails(context, syncedItem, ref);
} else {
await ref.read(syncProvider.notifier).addSyncItem(context, this);
}
context.refreshData();
},
)
else if (downloadUrl != null) ...[
ItemActionButton(
icon: const Icon(IconsaxPlusLinear.document_download),

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/l10n/generated/app_localizations.dart';
import 'package:fladder/providers/sync/background_download_provider.dart';
///Only use for base translations, under normal circumstances ALWAYS use the widgets provided context
final localizationContextProvider = StateProvider<BuildContext?>((ref) => null);
@ -13,7 +14,12 @@ extension BuildContextExtension on BuildContext {
class LocalizationContextWrapper extends ConsumerStatefulWidget {
final Widget child;
const LocalizationContextWrapper({required this.child, super.key});
final Locale currentLocale;
const LocalizationContextWrapper({
required this.child,
required this.currentLocale,
super.key,
});
@override
ConsumerState<LocalizationContextWrapper> createState() => _LocalizationContextWrapperState();
@ -23,8 +29,21 @@ class _LocalizationContextWrapperState extends ConsumerState<LocalizationContext
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((event) {
updateLanguageContext();
}
@override
void didUpdateWidget(covariant LocalizationContextWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentLocale != widget.currentLocale) {
updateLanguageContext();
}
}
void updateLanguageContext() {
WidgetsBinding.instance.addPostFrameCallback((value) {
ref.read(localizationContextProvider.notifier).update((cb) => context);
ref.read(backgroundDownloaderProvider.notifier).updateTranslations(context);
});
}

View file

@ -0,0 +1,82 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:fladder/models/syncing/database_item.dart';
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/models/syncing/sync_item.dart';
Future<void> isarMigration(Ref ref, AppDatabase db, String savePath) async {
if (kIsWeb) return;
//Return if the database is already migrated or not empty
final isNotEmtpy = await db.select(db.databaseItems).get().then((value) => value.isNotEmpty);
if (isNotEmtpy) {
log('Isar database is not empty, skipping migration');
return;
}
//Open isar database
final applicationDirectory = await getApplicationDocumentsDirectory();
final isarPath = Directory(path.joinAll([applicationDirectory.path, 'Fladder', 'Database']));
await isarPath.create(recursive: true);
final isar = Isar.open(
schemas: [ISyncedItemSchema],
directory: isarPath.path,
);
//Fetch all synced items from the old database
List<SyncedItem> items = isar.iSyncedItems
.where()
.findAll()
.map((e) => SyncedItem.fromIsar(e, path.joinAll([savePath, e.userId ?? "Unkown User"])))
.toList();
//Clear any missing paths
items = items.where((e) => e.path != null ? Directory(e.path!).existsSync() : false).toList();
//Convert to drift database items
final driftItems = items.map(
(item) => DatabaseItemsCompanion(
id: Value(item.id),
parentId: Value(item.parentId),
syncing: Value(item.syncing),
userId: Value(item.userId),
path: Value(item.path),
fileSize: Value(item.fileSize),
sortName: Value(item.sortName),
videoFileName: Value(item.videoFileName),
trickPlayModel: Value(item.fTrickPlayModel != null ? jsonEncode(item.fTrickPlayModel?.toJson()) : null),
mediaSegments: Value(item.mediaSegments != null ? jsonEncode(item.mediaSegments?.toJson()) : null),
images: Value(item.fImages != null ? jsonEncode(item.fImages?.toJson()) : null),
chapters: Value(jsonEncode(item.fChapters.map((e) => e.toJson()).toList())),
subtitles: Value(jsonEncode(item.subtitles.map((e) => e.toJson()).toList())),
userData: Value(item.userData != null ? jsonEncode(item.userData?.toJson()) : null),
),
);
await db.batch((batch) {
batch.insertAll(
db.databaseItems,
driftItems,
mode: InsertMode.insertOrReplace,
);
});
isar.close();
//Delete isar database after a few versions?
// await Future.delayed(const Duration(seconds: 1));
// if (await isarPath.exists()) {
// log('Deleting old Fladder base folder: ${isarPath.path}');
// await isarPath.delete(recursive: true);
// }
}

View file

@ -35,6 +35,8 @@ extension RefreshContextExtension on BuildContext {
Future<void> refreshData() async {
//Small delay to fix server not updating response based on successful query
await Future.delayed(const Duration(milliseconds: 250));
await RefreshState.maybeOf(this)?.refresh();
if (mounted) {
await RefreshState.maybeOf(this)?.refresh();
}
}
}

View file

@ -59,8 +59,9 @@ class _BackgroundImageState extends ConsumerState<BackgroundImage> {
if (itemId == null) return;
final apiResponse = await ref.read(jellyApiProvider).usersUserIdItemsItemIdGet(itemId: itemId);
final image =
apiResponse.body?.parentBaseModel.getPosters?.randomBackDrop ?? apiResponse.body?.getPosters?.randomBackDrop;
final image = apiResponse.body?.parentBaseModel.getPosters?.randomBackDrop ??
apiResponse.body?.getPosters?.randomBackDrop ??
apiResponse.body?.getPosters?.primary;
if (mounted) setState(() => backgroundImage = image);
});

View file

@ -30,7 +30,7 @@ class ActionContent extends StatelessWidget {
height: 4,
),
],
Expanded(child: child),
Flexible(child: child),
if (actions.isNotEmpty) ...[
if (showDividers)
const Divider(

View file

@ -1,9 +1,10 @@
import 'dart:async';
import 'dart:developer';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:flutter/material.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
class IconButtonAwait extends StatefulWidget {
final FutureOr<dynamic> Function() onPressed;
final Color? color;
@ -33,7 +34,11 @@ class IconButtonAwaitState extends State<IconButtonAwait> {
} catch (e) {
log(e.toString());
} finally {
setState(() => loading = false);
if (mounted) {
setState(() {
loading = false;
});
}
}
},
icon: AnimatedFadeSize(

View file

@ -12,7 +12,7 @@ class StatusCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.all(5),
padding: const EdgeInsets.all(2),
child: SizedBox.square(
dimension: 33,
child: Card(