diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 77a52bc..668b991 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1291,5 +1291,6 @@ "syncDeleteAll": "Delete all files", "syncAllFiles": "Sync all files", "usePostersForLibraryIconsTitle": "Show posters for library icons", - "usePostersForLibraryIconsDesc": "Show posters instead of icons for libraries" + "usePostersForLibraryIconsDesc": "Show posters instead of icons for libraries", + "offline": "Offline" } \ No newline at end of file diff --git a/lib/models/items/item_shared_models.dart b/lib/models/items/item_shared_models.dart index 370fc7d..97f2389 100644 --- a/lib/models/items/item_shared_models.dart +++ b/lib/models/items/item_shared_models.dart @@ -48,8 +48,42 @@ class UserData with UserDataMappable { ); } + static dto.UserItemDataDto toDto(UserData? data) { + if (data == null) { + return const dto.UserItemDataDto(); + } + return dto.UserItemDataDto( + isFavorite: data.isFavourite, + playCount: data.playCount, + playbackPositionTicks: data.playbackPositionTicks, + played: data.played, + unplayedItemCount: data.unPlayedItemCount, + lastPlayedDate: data.lastPlayed, + playedPercentage: data.progress, + ); + } + Duration get playBackPosition => Duration(milliseconds: playbackPositionTicks ~/ 10000); + // Returns null if unplayed with no progress + static bool? isPlayed(Duration position, Duration totalDuration) { + Duration startBuffer = totalDuration * 0.05; + Duration endBuffer = totalDuration * 0.90; + + Duration validStart = startBuffer; + Duration validEnd = endBuffer; + + if (position <= validStart) { + return null; + } + + if (position <= validEnd) { + return false; + } + + return true; + } + static UserData? determineLastUserData(List data) { return data.where((data) => data != null).reduce((a, b) { final aDate = a?.lastPlayed; diff --git a/lib/models/playback/offline_playback_model.dart b/lib/models/playback/offline_playback_model.dart index bc38ff0..e5cf957 100644 --- a/lib/models/playback/offline_playback_model.dart +++ b/lib/models/playback/offline_playback_model.dart @@ -66,35 +66,36 @@ class OfflinePlaybackModel extends PlaybackModel { @override Future playbackStopped(Duration position, Duration? totalDuration, Ref ref) async { + final progress = position.inMilliseconds / (item.overview.runTime?.inMilliseconds ?? 0) * 100; + final isPlayed = UserData.isPlayed(position, item.overview.runTime ?? Duration.zero); + final userData = syncedItem.userData?.copyWith( + playbackPositionTicks: isPlayed != false ? 0 : position.toRuntimeTicks, + progress: isPlayed != false ? 0.0 : progress, + played: isPlayed, + lastPlayed: DateTime.now().toUtc(), + ); + final newItem = syncedItem.copyWith( + userData: userData, + ); + await ref.read(syncProvider.notifier).updateItem(newItem); return null; } @override Future updatePlaybackPosition(Duration position, bool isPlaying, Ref ref) async { final progress = position.inMilliseconds / (item.overview.runTime?.inMilliseconds ?? 0) * 100; + final isPlayed = UserData.isPlayed(position, item.overview.runTime ?? Duration.zero); + final userData = syncedItem.userData?.copyWith( + playbackPositionTicks: isPlayed != false ? 0 : position.toRuntimeTicks, + progress: isPlayed != false ? 0.0 : progress, + played: isPlayed, + lastPlayed: DateTime.now().toUtc(), + ); final newItem = syncedItem.copyWith( - userData: syncedItem.userData?.copyWith( - playbackPositionTicks: position.toRuntimeTicks, - progress: progress, - played: isPlayed(position, item.overview.runTime ?? Duration.zero), - ), + userData: userData, ); await ref.read(syncProvider.notifier).updateItem(newItem); - return this; - } - - bool isPlayed(Duration position, Duration totalDuration) { - Duration startBuffer = totalDuration * 0.05; - Duration endBuffer = totalDuration * 0.90; - - Duration validStart = startBuffer; - Duration validEnd = endBuffer; - - if (position >= validStart && position <= validEnd) { - return true; - } - - return false; + return null; } @override diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 0d2ae7e..33ec571 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -1,6 +1,6 @@ import 'dart:developer'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide ConnectionState; import 'package:background_downloader/background_downloader.dart'; import 'package:chopper/chopper.dart'; @@ -26,6 +26,7 @@ import 'package:fladder/models/syncing/sync_item.dart'; import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/profiles/default_profile.dart'; import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/sync_provider.dart'; @@ -132,7 +133,7 @@ class PlaybackModelHelper { await _createOfflinePlaybackModel( newItem, null, - await ref.read(syncProvider.notifier).getSyncedItem(newItem), + await ref.read(syncProvider.notifier).getSyncedItem(newItem.id), oldModel: currentModel, ); if (newModel == null) return null; @@ -147,11 +148,12 @@ class PlaybackModelHelper { PlaybackModel? oldModel, }) async { final ItemBaseModel? syncedItemModel = syncedItem?.itemModel; - if (syncedItemModel == null || syncedItem == null || !syncedItem.dataFile.existsSync()) return null; + if (syncedItemModel == null || syncedItem == null || !await syncedItem.videoFile.exists()) return null; - 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)); + final children = await ref.read(syncProvider.notifier).getSiblings(syncedItem); + final syncedItems = + children.where((element) => element.videoFile.existsSync() && element.id != syncedItem.id).toList(); + final itemQueue = syncedItems.map((e) => e.itemModel).nonNulls; return OfflinePlaybackModel( item: syncedItemModel, @@ -173,11 +175,11 @@ class PlaybackModelHelper { bool showPlaybackOptions = false, Duration? startPosition, }) async { - if (item == null) return null; - final userId = ref.read(userProvider)?.id; - if (userId?.isEmpty == true) return null; - try { + if (item == null) return null; + final userId = ref.read(userProvider)?.id; + if (userId?.isEmpty == true) return null; + final queue = oldModel?.queue ?? libraryQueue ?? await collectQueue(item); final firstItemToPlay = switch (item) { @@ -191,7 +193,7 @@ class PlaybackModelHelper { if (fullItem == null) return null; - SyncedItem? syncedItem = await ref.read(syncProvider.notifier).getSyncedItem(fullItem); + SyncedItem? syncedItem = await ref.read(syncProvider.notifier).getSyncedItem(fullItem.id); final firstItemIsSynced = syncedItem != null && syncedItem.status == TaskStatus.complete; @@ -201,7 +203,9 @@ class PlaybackModelHelper { if (firstItemIsSynced) PlaybackType.offline, }; - if ((showPlaybackOptions || firstItemIsSynced) && context != null) { + final isOffline = ref.read(connectivityStatusProvider.select((value) => value == ConnectionState.offline)); + + if (((showPlaybackOptions || firstItemIsSynced) && !isOffline) && context != null) { final playbackType = await showPlaybackTypeSelection( context: context, options: options, @@ -241,16 +245,7 @@ class PlaybackModelHelper { ); } } 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, - ); - } + log("Error creating playback model: ${e.toString()}"); return null; } } diff --git a/lib/models/playback/playback_options_dialogue.dart b/lib/models/playback/playback_options_dialogue.dart index 7be861e..c016dfe 100644 --- a/lib/models/playback/playback_options_dialogue.dart +++ b/lib/models/playback/playback_options_dialogue.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:fladder/models/video_stream_model.dart'; -import 'package:fladder/screens/shared/adaptive_dialog.dart'; import 'package:fladder/util/localization_helper.dart'; Future showPlaybackTypeSelection({ @@ -10,17 +9,18 @@ Future showPlaybackTypeSelection({ }) async { PlaybackType? playbackType; - await showDialogAdaptive( + await showDialog( context: context, - builder: (context) { - return PlaybackDialogue( + useSafeArea: false, + builder: (context) => Dialog( + child: PlaybackDialogue( options: options, onClose: (type) { playbackType = type; Navigator.of(context).pop(); }, - ); - }, + ), + ), ); return playbackType; } diff --git a/lib/models/syncing/database_item.dart b/lib/models/syncing/database_item.dart index 696af00..5288e8a 100644 --- a/lib/models/syncing/database_item.dart +++ b/lib/models/syncing/database_item.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:fladder/models/item_base_model.dart'; @@ -17,11 +19,13 @@ import 'package:fladder/providers/user_provider.dart'; part 'database_item.g.dart'; +const _databseName = 'syncedDatabase'; + @TableIndex(name: 'database_id', columns: {#userId, #id}) class DatabaseItems extends Table { TextColumn get userId => text().withLength(min: 1)(); TextColumn get id => text().withLength(min: 1)(); - BoolColumn get syncing => boolean()(); + BoolColumn get syncing => boolean().withDefault(const Constant(false))(); TextColumn get sortName => text().nullable()(); TextColumn get parentId => text().nullable()(); TextColumn get path => text().nullable()(); @@ -32,6 +36,7 @@ class DatabaseItems extends Table { TextColumn get images => text().nullable()(); TextColumn get chapters => text().nullable()(); TextColumn get subtitles => text().nullable()(); + BoolColumn get unSyncedData => boolean().withDefault(const Constant(false))(); TextColumn get userData => text().nullable()(); @override @@ -47,14 +52,15 @@ class AppDatabase extends _$AppDatabase { String get userId => ref.read(userProvider.select((value) => value?.id ?? "")); @override - int get schemaVersion => 2; + int get schemaVersion => 1; - Future clearDatabase() { - return transaction(() async { - for (final table in allTables) { - await delete(table).go(); - } - }); + Future clearDatabase() async { + final dbPath = await getApplicationSupportDirectory(); + final dbFile = File(p.join(dbPath.path, '$_databseName.sqlite')); + + if (await dbFile.exists()) { + await dbFile.delete(recursive: true); + } } Selectable getItem(String id) => @@ -69,6 +75,10 @@ class AppDatabase extends _$AppDatabase { ..orderBy([(t) => OrderingTerm(expression: t.sortName)])) .map(databaseConverter); + Selectable get getAllItems => ((select(databaseItems)..where((tbl) => tbl.userId.equals(userId))) + ..orderBy([(t) => OrderingTerm(expression: t.sortName)])) + .map(databaseConverter); + Selectable getChildren(String parentId) => ((select(databaseItems)..where((tbl) => (tbl.parentId.equals(parentId) & tbl.userId.equals(userId)))) ..orderBy([(t) => OrderingTerm(expression: t.sortName)])) @@ -90,11 +100,9 @@ class AppDatabase extends _$AppDatabase { if (itemType == null) return []; final int maxDepth = switch (itemType) { - FladderItemType.episode => 0, - FladderItemType.movie => 0, FladderItemType.season => 1, FladderItemType.series => 2, - _ => 1, + _ => 0, }; final all = []; @@ -151,6 +159,7 @@ class AppDatabase extends _$AppDatabase { 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), + unSyncedData: Value(item.unSyncedData), ); } @@ -176,6 +185,7 @@ class AppDatabase extends _$AppDatabase { ? (jsonDecode(dataItem.subtitles!) as List).map((e) => SubStreamModel.fromJson(e)).toList() : [], userData: dataItem.userData != null ? UserData.fromJson(jsonDecode(dataItem.userData!)) : null, + unSyncedData: dataItem.unSyncedData, ); return syncedItem.copyWith( @@ -185,34 +195,11 @@ class AppDatabase extends _$AppDatabase { static QueryExecutor _openConnection() { return driftDatabase( - name: 'syncedDatabase', + name: _databseName, native: const DriftNativeOptions( databaseDirectory: getApplicationSupportDirectory, ), // If you need web support, see https://drift.simonbinder.eu/platforms/web/ ); } - - @override - MigrationStrategy get migration { - return MigrationStrategy( - onCreate: (Migrator m) { - return m.createAll(); - }, - onUpgrade: (Migrator m, int from, int to) async { - if (from == 1) { - final allItems = await select(databaseItems).get(); - m.deleteTable(databaseItems.actualTableName); - m.createAll(); - await batch((batch) { - batch.insertAll( - databaseItems, - allItems, - mode: InsertMode.insertOrReplace, - ); - }); - } - }, - ); - } } diff --git a/lib/models/syncing/database_item.g.dart b/lib/models/syncing/database_item.g.dart index d379aca..8570d7c 100644 --- a/lib/models/syncing/database_item.g.dart +++ b/lib/models/syncing/database_item.g.dart @@ -33,9 +33,10 @@ class $DatabaseItemsTable extends DatabaseItems late final GeneratedColumn syncing = GeneratedColumn( 'syncing', aliasedName, false, type: DriftSqlType.bool, - requiredDuringInsert: true, + requiredDuringInsert: false, defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("syncing" IN (0, 1))')); + GeneratedColumn.constraintIsAlways('CHECK ("syncing" IN (0, 1))'), + defaultValue: const Constant(false)); static const VerificationMeta _sortNameMeta = const VerificationMeta('sortName'); @override @@ -94,6 +95,16 @@ class $DatabaseItemsTable extends DatabaseItems late final GeneratedColumn subtitles = GeneratedColumn( 'subtitles', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _unSyncedDataMeta = + const VerificationMeta('unSyncedData'); + @override + late final GeneratedColumn unSyncedData = GeneratedColumn( + 'un_synced_data', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("un_synced_data" IN (0, 1))'), + defaultValue: const Constant(false)); static const VerificationMeta _userDataMeta = const VerificationMeta('userData'); @override @@ -115,6 +126,7 @@ class $DatabaseItemsTable extends DatabaseItems images, chapters, subtitles, + unSyncedData, userData ]; @override @@ -141,8 +153,6 @@ class $DatabaseItemsTable extends DatabaseItems if (data.containsKey('syncing')) { context.handle(_syncingMeta, syncing.isAcceptableOrUnknown(data['syncing']!, _syncingMeta)); - } else if (isInserting) { - context.missing(_syncingMeta); } if (data.containsKey('sort_name')) { context.handle(_sortNameMeta, @@ -190,6 +200,12 @@ class $DatabaseItemsTable extends DatabaseItems context.handle(_subtitlesMeta, subtitles.isAcceptableOrUnknown(data['subtitles']!, _subtitlesMeta)); } + if (data.containsKey('un_synced_data')) { + context.handle( + _unSyncedDataMeta, + unSyncedData.isAcceptableOrUnknown( + data['un_synced_data']!, _unSyncedDataMeta)); + } if (data.containsKey('user_data')) { context.handle(_userDataMeta, userData.isAcceptableOrUnknown(data['user_data']!, _userDataMeta)); @@ -229,6 +245,8 @@ class $DatabaseItemsTable extends DatabaseItems .read(DriftSqlType.string, data['${effectivePrefix}chapters']), subtitles: attachedDatabase.typeMapping .read(DriftSqlType.string, data['${effectivePrefix}subtitles']), + unSyncedData: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}un_synced_data'])!, userData: attachedDatabase.typeMapping .read(DriftSqlType.string, data['${effectivePrefix}user_data']), ); @@ -254,6 +272,7 @@ class DatabaseItem extends DataClass implements Insertable { final String? images; final String? chapters; final String? subtitles; + final bool unSyncedData; final String? userData; const DatabaseItem( {required this.userId, @@ -269,6 +288,7 @@ class DatabaseItem extends DataClass implements Insertable { this.images, this.chapters, this.subtitles, + required this.unSyncedData, this.userData}); @override Map toColumns(bool nullToAbsent) { @@ -306,6 +326,7 @@ class DatabaseItem extends DataClass implements Insertable { if (!nullToAbsent || subtitles != null) { map['subtitles'] = Variable(subtitles); } + map['un_synced_data'] = Variable(unSyncedData); if (!nullToAbsent || userData != null) { map['user_data'] = Variable(userData); } @@ -344,6 +365,7 @@ class DatabaseItem extends DataClass implements Insertable { subtitles: subtitles == null && nullToAbsent ? const Value.absent() : Value(subtitles), + unSyncedData: Value(unSyncedData), userData: userData == null && nullToAbsent ? const Value.absent() : Value(userData), @@ -367,6 +389,7 @@ class DatabaseItem extends DataClass implements Insertable { images: serializer.fromJson(json['images']), chapters: serializer.fromJson(json['chapters']), subtitles: serializer.fromJson(json['subtitles']), + unSyncedData: serializer.fromJson(json['unSyncedData']), userData: serializer.fromJson(json['userData']), ); } @@ -387,6 +410,7 @@ class DatabaseItem extends DataClass implements Insertable { 'images': serializer.toJson(images), 'chapters': serializer.toJson(chapters), 'subtitles': serializer.toJson(subtitles), + 'unSyncedData': serializer.toJson(unSyncedData), 'userData': serializer.toJson(userData), }; } @@ -405,6 +429,7 @@ class DatabaseItem extends DataClass implements Insertable { Value images = const Value.absent(), Value chapters = const Value.absent(), Value subtitles = const Value.absent(), + bool? unSyncedData, Value userData = const Value.absent()}) => DatabaseItem( userId: userId ?? this.userId, @@ -423,6 +448,7 @@ class DatabaseItem extends DataClass implements Insertable { images: images.present ? images.value : this.images, chapters: chapters.present ? chapters.value : this.chapters, subtitles: subtitles.present ? subtitles.value : this.subtitles, + unSyncedData: unSyncedData ?? this.unSyncedData, userData: userData.present ? userData.value : this.userData, ); DatabaseItem copyWithCompanion(DatabaseItemsCompanion data) { @@ -446,6 +472,9 @@ class DatabaseItem extends DataClass implements Insertable { images: data.images.present ? data.images.value : this.images, chapters: data.chapters.present ? data.chapters.value : this.chapters, subtitles: data.subtitles.present ? data.subtitles.value : this.subtitles, + unSyncedData: data.unSyncedData.present + ? data.unSyncedData.value + : this.unSyncedData, userData: data.userData.present ? data.userData.value : this.userData, ); } @@ -466,6 +495,7 @@ class DatabaseItem extends DataClass implements Insertable { ..write('images: $images, ') ..write('chapters: $chapters, ') ..write('subtitles: $subtitles, ') + ..write('unSyncedData: $unSyncedData, ') ..write('userData: $userData') ..write(')')) .toString(); @@ -486,6 +516,7 @@ class DatabaseItem extends DataClass implements Insertable { images, chapters, subtitles, + unSyncedData, userData); @override bool operator ==(Object other) => @@ -504,6 +535,7 @@ class DatabaseItem extends DataClass implements Insertable { other.images == this.images && other.chapters == this.chapters && other.subtitles == this.subtitles && + other.unSyncedData == this.unSyncedData && other.userData == this.userData); } @@ -521,6 +553,7 @@ class DatabaseItemsCompanion extends UpdateCompanion { final Value images; final Value chapters; final Value subtitles; + final Value unSyncedData; final Value userData; final Value rowid; const DatabaseItemsCompanion({ @@ -537,13 +570,14 @@ class DatabaseItemsCompanion extends UpdateCompanion { this.images = const Value.absent(), this.chapters = const Value.absent(), this.subtitles = const Value.absent(), + this.unSyncedData = const Value.absent(), this.userData = const Value.absent(), this.rowid = const Value.absent(), }); DatabaseItemsCompanion.insert({ required String userId, required String id, - required bool syncing, + this.syncing = const Value.absent(), this.sortName = const Value.absent(), this.parentId = const Value.absent(), this.path = const Value.absent(), @@ -554,11 +588,11 @@ class DatabaseItemsCompanion extends UpdateCompanion { this.images = const Value.absent(), this.chapters = const Value.absent(), this.subtitles = const Value.absent(), + this.unSyncedData = const Value.absent(), this.userData = const Value.absent(), this.rowid = const Value.absent(), }) : userId = Value(userId), - id = Value(id), - syncing = Value(syncing); + id = Value(id); static Insertable custom({ Expression? userId, Expression? id, @@ -573,6 +607,7 @@ class DatabaseItemsCompanion extends UpdateCompanion { Expression? images, Expression? chapters, Expression? subtitles, + Expression? unSyncedData, Expression? userData, Expression? rowid, }) { @@ -590,6 +625,7 @@ class DatabaseItemsCompanion extends UpdateCompanion { if (images != null) 'images': images, if (chapters != null) 'chapters': chapters, if (subtitles != null) 'subtitles': subtitles, + if (unSyncedData != null) 'un_synced_data': unSyncedData, if (userData != null) 'user_data': userData, if (rowid != null) 'rowid': rowid, }); @@ -609,6 +645,7 @@ class DatabaseItemsCompanion extends UpdateCompanion { Value? images, Value? chapters, Value? subtitles, + Value? unSyncedData, Value? userData, Value? rowid}) { return DatabaseItemsCompanion( @@ -625,6 +662,7 @@ class DatabaseItemsCompanion extends UpdateCompanion { images: images ?? this.images, chapters: chapters ?? this.chapters, subtitles: subtitles ?? this.subtitles, + unSyncedData: unSyncedData ?? this.unSyncedData, userData: userData ?? this.userData, rowid: rowid ?? this.rowid, ); @@ -672,6 +710,9 @@ class DatabaseItemsCompanion extends UpdateCompanion { if (subtitles.present) { map['subtitles'] = Variable(subtitles.value); } + if (unSyncedData.present) { + map['un_synced_data'] = Variable(unSyncedData.value); + } if (userData.present) { map['user_data'] = Variable(userData.value); } @@ -697,6 +738,7 @@ class DatabaseItemsCompanion extends UpdateCompanion { ..write('images: $images, ') ..write('chapters: $chapters, ') ..write('subtitles: $subtitles, ') + ..write('unSyncedData: $unSyncedData, ') ..write('userData: $userData, ') ..write('rowid: $rowid') ..write(')')) @@ -722,7 +764,7 @@ typedef $$DatabaseItemsTableCreateCompanionBuilder = DatabaseItemsCompanion Function({ required String userId, required String id, - required bool syncing, + Value syncing, Value sortName, Value parentId, Value path, @@ -733,6 +775,7 @@ typedef $$DatabaseItemsTableCreateCompanionBuilder = DatabaseItemsCompanion Value images, Value chapters, Value subtitles, + Value unSyncedData, Value userData, Value rowid, }); @@ -751,6 +794,7 @@ typedef $$DatabaseItemsTableUpdateCompanionBuilder = DatabaseItemsCompanion Value images, Value chapters, Value subtitles, + Value unSyncedData, Value userData, Value rowid, }); @@ -804,6 +848,9 @@ class $$DatabaseItemsTableFilterComposer ColumnFilters get subtitles => $composableBuilder( column: $table.subtitles, builder: (column) => ColumnFilters(column)); + ColumnFilters get unSyncedData => $composableBuilder( + column: $table.unSyncedData, builder: (column) => ColumnFilters(column)); + ColumnFilters get userData => $composableBuilder( column: $table.userData, builder: (column) => ColumnFilters(column)); } @@ -859,6 +906,10 @@ class $$DatabaseItemsTableOrderingComposer ColumnOrderings get subtitles => $composableBuilder( column: $table.subtitles, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get unSyncedData => $composableBuilder( + column: $table.unSyncedData, + builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userData => $composableBuilder( column: $table.userData, builder: (column) => ColumnOrderings(column)); } @@ -911,6 +962,9 @@ class $$DatabaseItemsTableAnnotationComposer GeneratedColumn get subtitles => $composableBuilder(column: $table.subtitles, builder: (column) => column); + GeneratedColumn get unSyncedData => $composableBuilder( + column: $table.unSyncedData, builder: (column) => column); + GeneratedColumn get userData => $composableBuilder(column: $table.userData, builder: (column) => column); } @@ -954,6 +1008,7 @@ class $$DatabaseItemsTableTableManager extends RootTableManager< Value images = const Value.absent(), Value chapters = const Value.absent(), Value subtitles = const Value.absent(), + Value unSyncedData = const Value.absent(), Value userData = const Value.absent(), Value rowid = const Value.absent(), }) => @@ -971,13 +1026,14 @@ class $$DatabaseItemsTableTableManager extends RootTableManager< images: images, chapters: chapters, subtitles: subtitles, + unSyncedData: unSyncedData, userData: userData, rowid: rowid, ), createCompanionCallback: ({ required String userId, required String id, - required bool syncing, + Value syncing = const Value.absent(), Value sortName = const Value.absent(), Value parentId = const Value.absent(), Value path = const Value.absent(), @@ -988,6 +1044,7 @@ class $$DatabaseItemsTableTableManager extends RootTableManager< Value images = const Value.absent(), Value chapters = const Value.absent(), Value subtitles = const Value.absent(), + Value unSyncedData = const Value.absent(), Value userData = const Value.absent(), Value rowid = const Value.absent(), }) => @@ -1005,6 +1062,7 @@ class $$DatabaseItemsTableTableManager extends RootTableManager< images: images, chapters: chapters, subtitles: subtitles, + unSyncedData: unSyncedData, userData: userData, rowid: rowid, ), diff --git a/lib/models/syncing/sync_item.dart b/lib/models/syncing/sync_item.dart index 86b38aa..442a97a 100644 --- a/lib/models/syncing/sync_item.dart +++ b/lib/models/syncing/sync_item.dart @@ -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_provider.dart'; import 'package:fladder/util/localization_helper.dart'; part 'sync_item.freezed.dart'; @@ -42,6 +41,7 @@ class SyncedItem with _$SyncedItem { ImagesData? fImages, @Default([]) List fChapters, @Default([]) List subtitles, + @Default(false) bool unSyncedData, @UserDataJsonSerializer() UserData? userData, // ignore: invalid_annotation_target @JsonKey(includeFromJson: false, includeToJson: false) ItemBaseModel? itemModel, @@ -67,6 +67,13 @@ class SyncedItem with _$SyncedItem { []); File get dataFile => File(joinAll(["$path", "data.json"])); + BaseItemDto? get data { + return dataFile.existsSync() + ? BaseItemDto.fromJson(jsonDecode(dataFile.readAsStringSync())) + .copyWith(userData: UserData.toDto(userData), path: videoFile.existsSync() ? videoFile.path : '') + : null; + } + Directory get trickPlayDirectory => Directory(joinAll(["$path", trickPlayPath])); File get videoFile => File(joinAll(["$path", "$videoFileName"])); Directory get directory => Directory(path ?? ""); @@ -104,10 +111,6 @@ class SyncedItem with _$SyncedItem { return true; } - Future> getChildren(Ref ref) async => await ref.read(syncProvider.notifier).getChildren(this); - Future> getNestedChildren(Ref ref) async => - await ref.read(syncProvider.notifier).getNestedChildren(this); - Future get getDirSize async { var files = await directory.list(recursive: true).toList(); var dirSize = files.fold(0, (int sum, file) => sum + file.statSync().size); diff --git a/lib/models/syncing/sync_item.freezed.dart b/lib/models/syncing/sync_item.freezed.dart index b99f71e..2be0bd9 100644 --- a/lib/models/syncing/sync_item.freezed.dart +++ b/lib/models/syncing/sync_item.freezed.dart @@ -30,6 +30,7 @@ mixin _$SyncedItem { ImagesData? get fImages => throw _privateConstructorUsedError; List get fChapters => throw _privateConstructorUsedError; List get subtitles => throw _privateConstructorUsedError; + bool get unSyncedData => throw _privateConstructorUsedError; @UserDataJsonSerializer() UserData? get userData => throw _privateConstructorUsedError; // ignore: invalid_annotation_target @@ -64,6 +65,7 @@ abstract class $SyncedItemCopyWith<$Res> { ImagesData? fImages, List fChapters, List subtitles, + bool unSyncedData, @UserDataJsonSerializer() UserData? userData, @JsonKey(includeFromJson: false, includeToJson: false) ItemBaseModel? itemModel}); @@ -100,6 +102,7 @@ class _$SyncedItemCopyWithImpl<$Res, $Val extends SyncedItem> Object? fImages = freezed, Object? fChapters = null, Object? subtitles = null, + Object? unSyncedData = null, Object? userData = freezed, Object? itemModel = freezed, }) { @@ -160,6 +163,10 @@ class _$SyncedItemCopyWithImpl<$Res, $Val extends SyncedItem> ? _value.subtitles : subtitles // ignore: cast_nullable_to_non_nullable as List, + unSyncedData: null == unSyncedData + ? _value.unSyncedData + : unSyncedData // ignore: cast_nullable_to_non_nullable + as bool, userData: freezed == userData ? _value.userData : userData // ignore: cast_nullable_to_non_nullable @@ -209,6 +216,7 @@ abstract class _$$SyncItemImplCopyWith<$Res> ImagesData? fImages, List fChapters, List subtitles, + bool unSyncedData, @UserDataJsonSerializer() UserData? userData, @JsonKey(includeFromJson: false, includeToJson: false) ItemBaseModel? itemModel}); @@ -244,6 +252,7 @@ class __$$SyncItemImplCopyWithImpl<$Res> Object? fImages = freezed, Object? fChapters = null, Object? subtitles = null, + Object? unSyncedData = null, Object? userData = freezed, Object? itemModel = freezed, }) { @@ -304,6 +313,10 @@ class __$$SyncItemImplCopyWithImpl<$Res> ? _value._subtitles : subtitles // ignore: cast_nullable_to_non_nullable as List, + unSyncedData: null == unSyncedData + ? _value.unSyncedData + : unSyncedData // ignore: cast_nullable_to_non_nullable + as bool, userData: freezed == userData ? _value.userData : userData // ignore: cast_nullable_to_non_nullable @@ -334,6 +347,7 @@ class _$SyncItemImpl extends _SyncItem { this.fImages, final List fChapters = const [], final List subtitles = const [], + this.unSyncedData = false, @UserDataJsonSerializer() this.userData, @JsonKey(includeFromJson: false, includeToJson: false) this.itemModel}) : _fChapters = fChapters, @@ -384,6 +398,9 @@ class _$SyncItemImpl extends _SyncItem { return EqualUnmodifiableListView(_subtitles); } + @override + @JsonKey() + final bool unSyncedData; @override @UserDataJsonSerializer() final UserData? userData; @@ -394,7 +411,7 @@ class _$SyncItemImpl extends _SyncItem { @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, itemModel: $itemModel)'; + 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, unSyncedData: $unSyncedData, userData: $userData, itemModel: $itemModel)'; } /// Create a copy of SyncedItem @@ -422,6 +439,7 @@ abstract class _SyncItem extends SyncedItem { final ImagesData? fImages, final List fChapters, final List subtitles, + final bool unSyncedData, @UserDataJsonSerializer() final UserData? userData, @JsonKey(includeFromJson: false, includeToJson: false) final ItemBaseModel? itemModel}) = _$SyncItemImpl; @@ -456,6 +474,8 @@ abstract class _SyncItem extends SyncedItem { @override List get subtitles; @override + bool get unSyncedData; + @override @UserDataJsonSerializer() UserData? get userData; // ignore: invalid_annotation_target @override diff --git a/lib/providers/api_provider.dart b/lib/providers/api_provider.dart index 6c14214..777360f 100644 --- a/lib/providers/api_provider.dart +++ b/lib/providers/api_provider.dart @@ -1,11 +1,13 @@ import 'dart:developer'; import 'package:chopper/chopper.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/providers/auth_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/user_provider.dart'; @@ -33,21 +35,27 @@ class JellyRequest implements Interceptor { @override FutureOr> intercept(Chain chain) async { - final serverUrl = Uri.parse(ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server); + final connectivityNotifier = ref.read(connectivityStatusProvider.notifier); + try { + final serverUrl = Uri.parse(ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server); - //Use current logged in user otherwise use the authprovider - var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).tempCredentials; - var headers = loginModel.header(ref); + //Use current logged in user otherwise use the authprovider + var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).tempCredentials; + var headers = loginModel.header(ref); + final Response response = await chain.proceed( + applyHeaders( + chain.request.copyWith( + baseUri: serverUrl, + ), + headers), + ); - final Response response = await chain.proceed( - applyHeaders( - chain.request.copyWith( - baseUri: serverUrl, - ), - headers), - ); - - return response; + connectivityNotifier.checkConnectivity(); + return response; + } catch (e) { + connectivityNotifier.onStateChange([ConnectivityResult.none]); + throw Exception('Failed to make request\n$e'); + } } } @@ -67,10 +75,6 @@ class JellyResponse implements Interceptor { chopperLogger.severe('404 NOT FOUND'); } - if (response.statusCode == 401) { - // ref.read(sharedUtilityProvider).removeAccount(ref.read(userProvider)); - } - return response; } } diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 5baa6f5..b10585c 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -10,7 +10,6 @@ import 'package:fladder/providers/favourites_provider.dart'; import 'package:fladder/providers/image_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/shared_provider.dart'; -import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/views_provider.dart'; @@ -92,7 +91,6 @@ class AuthNotifier extends StateNotifier { ref.read(viewsProvider.notifier).clear(); ref.read(favouritesProvider.notifier).clear(); ref.read(userProvider.notifier).clear(); - ref.read(syncProvider.notifier).setup(); } void setServer(String server) { diff --git a/lib/providers/connectivity_provider.dart b/lib/providers/connectivity_provider.dart index 692136b..486b30d 100644 --- a/lib/providers/connectivity_provider.dart +++ b/lib/providers/connectivity_provider.dart @@ -22,7 +22,8 @@ class ConnectivityStatus extends _$ConnectivityStatus { @override ConnectionState build() { Connectivity().onConnectivityChanged.listen(onStateChange); - return ConnectionState.offline; + checkConnectivity(); + return ConnectionState.mobile; } void onStateChange(List connectivityResult) { @@ -36,4 +37,9 @@ class ConnectivityStatus extends _$ConnectivityStatus { state = ConnectionState.offline; } } + + void checkConnectivity() async { + final connectivityResult = await Connectivity().checkConnectivity(); + onStateChange(connectivityResult); + } } diff --git a/lib/providers/connectivity_provider.g.dart b/lib/providers/connectivity_provider.g.dart index e9aeec6..648f44d 100644 --- a/lib/providers/connectivity_provider.g.dart +++ b/lib/providers/connectivity_provider.g.dart @@ -7,7 +7,7 @@ part of 'connectivity_provider.dart'; // ************************************************************************** String _$connectivityStatusHash() => - r'2e9b645c78146ed25456e1286df83761588b8e27'; + r'7a4ac96d163a479bd34fc6a3efcd556755f8d5e9'; /// See also [ConnectivityStatus]. @ProviderFor(ConnectivityStatus) diff --git a/lib/providers/items/episode_details_provider.dart b/lib/providers/items/episode_details_provider.dart index b73b7f7..a008036 100644 --- a/lib/providers/items/episode_details_provider.dart +++ b/lib/providers/items/episode_details_provider.dart @@ -74,9 +74,9 @@ class EpisodeDetailsProvider extends StateNotifier { Future _tryToCreateOfflineState(ItemBaseModel item) async { final syncNotifier = ref.read(syncProvider.notifier); - final episodeModel = (await syncNotifier.getSyncedItem(item))?.itemModel as EpisodeModel?; + final episodeModel = (await syncNotifier.getSyncedItem(item.id))?.itemModel as EpisodeModel?; if (episodeModel == null) return; - final seriesSyncedItem = await syncNotifier.getSyncedItem(episodeModel.parentBaseModel); + final seriesSyncedItem = await syncNotifier.getSyncedItem(episodeModel.parentBaseModel.id); if (seriesSyncedItem == null) return; final seriesModel = seriesSyncedItem.itemModel as SeriesModel?; if (seriesModel == null) return; diff --git a/lib/providers/items/movies_details_provider.dart b/lib/providers/items/movies_details_provider.dart index 1bd431a..3b99757 100644 --- a/lib/providers/items/movies_details_provider.dart +++ b/lib/providers/items/movies_details_provider.dart @@ -6,7 +6,6 @@ import 'package:fladder/models/items/movie_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/related_provider.dart'; import 'package:fladder/providers/service_provider.dart'; -import 'package:fladder/providers/sync_provider.dart'; part 'movies_details_provider.g.dart'; @@ -30,19 +29,10 @@ class MovieDetails extends _$MovieDetails { state = newState.copyWith(related: related.body); return null; } catch (e) { - _tryToCreateOfflineState(item); return null; } } - void _tryToCreateOfflineState(ItemBaseModel item) async { - final syncNotifier = ref.read(syncProvider.notifier); - final syncedItem = await syncNotifier.getParentItem(item.id); - if (syncedItem == null) return; - final movieModel = syncedItem.itemModel as MovieModel?; - state = movieModel; - } - void setSubIndex(int index) { state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(defaultSubStreamIndex: index)); } diff --git a/lib/providers/items/movies_details_provider.g.dart b/lib/providers/items/movies_details_provider.g.dart index 20c97d9..fcd6b53 100644 --- a/lib/providers/items/movies_details_provider.g.dart +++ b/lib/providers/items/movies_details_provider.g.dart @@ -6,7 +6,7 @@ part of 'movies_details_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$movieDetailsHash() => r'a9d8d2eeb7fa37652f25c1820b5e346efeeb59fc'; +String _$movieDetailsHash() => r'322236f235bcd387cfecc3468c0876490d9afc39'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/providers/items/series_details_provider.dart b/lib/providers/items/series_details_provider.dart index 36e52a0..1506346 100644 --- a/lib/providers/items/series_details_provider.dart +++ b/lib/providers/items/series_details_provider.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:chopper/chopper.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,7 +11,6 @@ import 'package:fladder/models/items/series_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/related_provider.dart'; import 'package:fladder/providers/service_provider.dart'; -import 'package:fladder/providers/sync_provider.dart'; final seriesDetailsProvider = StateNotifierProvider.autoDispose.family((ref, id) { @@ -33,6 +34,14 @@ class SeriesDetailViewNotifier extends StateNotifier { if (response.body == null) return null; newState = response.bodyOrThrow as SeriesModel; + final seasons = await api.showsSeriesIdSeasonsGet(seriesId: seriesModel.id, fields: [ + ItemFields.mediastreams, + ItemFields.mediasources, + ItemFields.overview, + ItemFields.candownload, + ItemFields.childcount, + ]); + final episodes = await api.showsSeriesIdEpisodesGet(seriesId: seriesModel.id, fields: [ ItemFields.mediastreams, ItemFields.mediasources, @@ -45,14 +54,9 @@ class SeriesDetailViewNotifier extends StateNotifier { 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( seasons: SeasonModel.seasonsFromDto(seasons.body?.items, ref) .map((element) => element.copyWith( @@ -71,26 +75,11 @@ class SeriesDetailViewNotifier extends StateNotifier { state = newState.copyWith(related: related.body); return response; } catch (e) { - _tryToCreateOfflineState(seriesModel); + log("Error fetching series details: $e"); return null; } } - Future _tryToCreateOfflineState(ItemBaseModel series) async { - final syncNotifier = ref.read(syncProvider.notifier); - final syncedItem = await syncNotifier.getSyncedItem(series); - if (syncedItem == null) return; - 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().toList(), - seasons: allChildren.whereType().toList(), - ); - } - return; - } - void updateEpisodeInfo(EpisodeModel episode) { final index = state?.availableEpisodes?.indexOf(episode); diff --git a/lib/providers/related_provider.dart b/lib/providers/related_provider.dart index 8a94a24..ebb8d62 100644 --- a/lib/providers/related_provider.dart +++ b/lib/providers/related_provider.dart @@ -1,9 +1,10 @@ import 'package:chopper/chopper.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/user_provider.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; final relatedUtilityProvider = Provider((ref) { return RelatedNotifier(ref: ref); diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart index a718ab2..3fd1be6 100644 --- a/lib/providers/service_provider.dart +++ b/lib/providers/service_provider.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:chopper/chopper.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; import 'package:path/path.dart'; import 'package:fladder/fake/fake_jellyfin_open_api.dart'; @@ -12,10 +13,13 @@ import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/account_model.dart'; import 'package:fladder/models/credentials_model.dart'; import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/items/episode_model.dart'; +import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/providers/auth_provider.dart'; import 'package:fladder/providers/image_provider.dart'; +import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/util/jellyfin_extension.dart'; @@ -84,21 +88,78 @@ class JellyService { Future> usersUserIdItemsItemIdGet({ String? itemId, }) async { - final response = await api.itemsItemIdGet( - userId: account?.id, - itemId: itemId, - ); - return response.copyWith(body: ItemBaseModel.fromBaseDto(response.bodyOrThrow, ref)); + try { + final response = await api.itemsItemIdGet( + userId: account?.id, + itemId: itemId, + ); + return response.copyWith(body: ItemBaseModel.fromBaseDto(response.bodyOrThrow, ref)); + } catch (e) { + final item = (await ref.read(syncProvider.notifier).getSyncedItem(itemId))?.itemModel; + return Response( + http.Response("", 202), + item, + ); + } } Future> usersUserIdItemsItemIdGetBaseItem({ String? itemId, }) async { - final response = await api.itemsItemIdGet( + try { + return await api.itemsItemIdGet( + userId: account?.id, + itemId: itemId, + ); + } catch (e) { + return ref.read(syncProvider.notifier).getSyncedItem(itemId).then( + (value) => value?.data != null + ? Response( + http.Response("", 202), + value?.data, + ) + : Response( + http.Response("", 404), + null, + ), + ); + } + } + + Future> userItemsItemIdUserDataGet({ + String? itemId, + }) async { + final response = await api.userItemsItemIdUserDataGet( userId: account?.id, itemId: itemId, ); - return response; + return response.copyWith( + body: UserData.fromDto(response.bodyOrThrow), + ); + } + + Future?> userItemsItemIdUserDataPost({ + String? itemId, + required UserData? body, + }) async { + if (body == null) { + return null; + } + final response = await api.userItemsItemIdUserDataPost( + userId: account?.id, + itemId: itemId, + body: UpdateUserItemDataDto( + playCount: body.playCount, + playbackPositionTicks: body.playbackPositionTicks, + isFavorite: body.isFavourite, + played: body.played, + lastPlayedDate: body.lastPlayed, + itemId: itemId, + ), + ); + return response.copyWith( + body: UserData.fromDto(response.bodyOrThrow), + ); } Future> itemsGet({ @@ -491,8 +552,15 @@ class JellyService { Future sessionsPlayingStoppedPost({ required PlaybackStopInfo? body, - }) => - api.sessionsPlayingStoppedPost(body: body); + }) { + final positionTicks = body?.positionTicks; + if (positionTicks != null) { + ref + .read(syncProvider.notifier) + .updatePlaybackPosition(itemId: body?.itemId, position: Duration(milliseconds: positionTicks ~/ 10000)); + } + return api.sessionsPlayingStoppedPost(body: body); + } Future sessionsPlayingProgressPost({required PlaybackProgressInfo? body}) async => api.sessionsPlayingProgressPost(body: body); @@ -533,25 +601,51 @@ class JellyService { bool? enableUserData, ShowsSeriesIdEpisodesGetSortBy? sortBy, }) async { - return api.showsSeriesIdEpisodesGet( - seriesId: seriesId, - userId: account?.id, - fields: [ - ...?fields, - ItemFields.parentid, - ], - isMissing: isMissing, - limit: limit, - sortBy: sortBy, - enableUserData: enableUserData, - startIndex: startIndex, - adjacentTo: adjacentTo, - startItemId: startItemId, - season: season, - seasonId: seasonId, - enableImages: enableImages, - enableImageTypes: enableImageTypes, - ); + try { + var response = await api.showsSeriesIdEpisodesGet( + seriesId: seriesId, + userId: account?.id, + fields: [ + ...?fields, + ItemFields.parentid, + ], + isMissing: isMissing, + limit: limit, + sortBy: sortBy, + enableUserData: enableUserData, + startIndex: startIndex, + adjacentTo: adjacentTo, + startItemId: startItemId, + season: season, + seasonId: seasonId, + enableImages: enableImages, + enableImageTypes: enableImageTypes, + ); + return response; + } catch (e) { + final seriesItem = await ref.read(syncProvider.notifier).getSyncedItem(seriesId); + if (seriesItem != null) { + final episodes = await ref.read(syncProvider.notifier).getNestedChildren(seriesItem) + ..where((e) => e.itemModel is EpisodeModel); + return Response( + http.Response("", 200), + BaseItemDtoQueryResult( + items: episodes.map((e) => e.data).nonNulls.toList(), + totalRecordCount: episodes.length, + startIndex: 0, + ), + ); + } else { + return Response( + http.Response("", 400), + const BaseItemDtoQueryResult( + items: [], + totalRecordCount: 0, + startIndex: 0, + ), + ); + } + } } Future> fetchEpisodeFromShow({ @@ -566,11 +660,22 @@ class JellyService { String? itemId, int? limit, }) async { - return api.itemsItemIdSimilarGet( - userId: account?.id, - itemId: itemId, - limit: limit, - ); + try { + return await api.itemsItemIdSimilarGet(userId: account?.id, itemId: itemId, limit: limit, fields: [ + ItemFields.parentid, + ItemFields.candelete, + ItemFields.candownload, + ]); + } catch (e) { + return Response( + http.Response("", 400), + const BaseItemDtoQueryResult( + items: [], + totalRecordCount: 0, + startIndex: 0, + ), + ); + } } Future> usersUserIdItemsGet({ @@ -692,8 +797,9 @@ class JellyService { bool? enableUserData, bool? isMissing, List? fields, - }) => - api.showsSeriesIdSeasonsGet( + }) async { + try { + final response = await api.showsSeriesIdSeasonsGet( seriesId: seriesId, isMissing: isMissing, enableUserData: enableUserData, @@ -702,6 +808,31 @@ class JellyService { ItemFields.parentid, ], ); + return response; + } catch (e) { + final seriesItem = await ref.read(syncProvider.notifier).getSyncedItem(seriesId); + if (seriesItem != null) { + final seasons = await ref.read(syncProvider.notifier).getChildren(seriesItem.id); + return Response( + http.Response("", 200), + BaseItemDtoQueryResult( + items: seasons.map((e) => e.data).nonNulls.toList(), + totalRecordCount: seasons.length, + startIndex: 0, + ), + ); + } else { + return Response( + http.Response("", 400), + const BaseItemDtoQueryResult( + items: [], + totalRecordCount: 0, + startIndex: 0, + ), + ); + } + } + } Future> itemsFilters2Get({ String? parentId, @@ -817,33 +948,75 @@ class JellyService { Future> usersUserIdFavoriteItemsItemIdPost({ required String? itemId, - }) => - api.userFavoriteItemsItemIdPost( + }) async { + Response? response; + try { + response = await api.userFavoriteItemsItemIdPost( itemId: itemId, userId: account?.id, ); + } finally { + await ref + .read(syncProvider.notifier) + .updateFavoriteItem(itemId, isFavorite: true, responseSuccessful: response?.isSuccessful ?? false); + } + return response; + } Future> usersUserIdFavoriteItemsItemIdDelete({ required String? itemId, - }) => - api.userFavoriteItemsItemIdDelete( + }) async { + Response? response; + try { + response = await api.userFavoriteItemsItemIdDelete( itemId: itemId, userId: account?.id, ); + } finally { + await ref + .read(syncProvider.notifier) + .updateFavoriteItem(itemId, isFavorite: false, responseSuccessful: response?.isSuccessful ?? false); + } + return response; + } Future> usersUserIdPlayedItemsItemIdPost({ required String? itemId, DateTime? datePlayed, - }) => - api.userPlayedItemsItemIdPost(itemId: itemId, userId: account?.id, datePlayed: datePlayed); + }) async { + Response? response; + try { + response = await api.userPlayedItemsItemIdPost(itemId: itemId, userId: account?.id, datePlayed: datePlayed); + } finally { + await ref.read(syncProvider.notifier).updatePlayedItem( + itemId, + datePlayed: datePlayed, + played: true, + responseSuccessful: response?.isSuccessful ?? false, + ); + } + return response; + } Future> usersUserIdPlayedItemsItemIdDelete({ required String? itemId, - }) => - api.userPlayedItemsItemIdDelete( + }) async { + Response? response; + try { + response = await api.userPlayedItemsItemIdDelete( itemId: itemId, userId: account?.id, ); + } finally { + await ref.read(syncProvider.notifier).updatePlayedItem( + itemId, + played: false, + responseSuccessful: response?.isSuccessful ?? false, + ); + } + + return response; + } Future?> mediaSegmentsGet({ required String id, diff --git a/lib/providers/settings/photo_view_settings_provider.dart b/lib/providers/settings/photo_view_settings_provider.dart index 96b9b63..25b089d 100644 --- a/lib/providers/settings/photo_view_settings_provider.dart +++ b/lib/providers/settings/photo_view_settings_provider.dart @@ -63,10 +63,6 @@ final photoViewSettingsProvider = StateNotifierProvider((ref) { - return 0; -}); - class PhotoViewSettingsNotifier extends StateNotifier { PhotoViewSettingsNotifier(this.ref) : super(PhotoViewSettingsModel()); diff --git a/lib/providers/sync/sync_provider_helpers.dart b/lib/providers/sync/sync_provider_helpers.dart index 010018e..74df967 100644 --- a/lib/providers/sync/sync_provider_helpers.dart +++ b/lib/providers/sync/sync_provider_helpers.dart @@ -16,13 +16,13 @@ Stream syncedItem(Ref ref, ItemBaseModel? item) { return Stream.value(null); } - return ref.watch(syncProvider.notifier).db.getItem(id).watchSingleOrNull(); + return ref.watch(syncProvider.notifier).watchItem(id); } @riverpod class SyncedChildren extends _$SyncedChildren { @override - FutureOr> build(SyncedItem item) => ref.read(syncProvider.notifier).getChildren(item); + FutureOr> build(SyncedItem item) => ref.read(syncProvider.notifier).getChildren(item.id); } @riverpod diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index aff5414..4b1800c 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -4,7 +4,7 @@ import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide ConnectionState; import 'package:background_downloader/background_downloader.dart'; import 'package:collection/collection.dart'; @@ -33,11 +33,13 @@ import 'package:fladder/models/syncing/sync_settings_model.dart'; import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/profiles/default_profile.dart'; import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; 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/duration_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/migration/isar_drift_migration.dart'; @@ -51,15 +53,49 @@ class SyncNotifier extends StateNotifier { } final Ref ref; - late final db = AppDatabase(ref); + late AppDatabase _db = AppDatabase(ref); final Directory mobileDirectory; final String subPath = "Synced"; + bool updatingSyncStatus = false; + + StreamSubscription>? _subscription; + + @override + set state(SyncSettingsModel value) { + super.state = value; + updateSyncStates(); + } + void migrateFromIsar() async { - await isarMigration(ref, db, mainDirectory.path); + await isarMigration(ref, _db, mainDirectory.path); _initializeQueryStream(); } + Future updateSyncStates() async { + final lastState = + (await _db.getAllItems.get()).where((item) => item.unSyncedData && item.userData != null).toList(); + if (updatingSyncStatus || lastState.isEmpty) return; + updatingSyncStatus = true; + try { + for (final item in lastState) { + if (item.userData == null) continue; + final updatedItem = + await ref.read(jellyApiProvider).userItemsItemIdUserDataPost(itemId: item.id, body: item.userData); + if (updatedItem?.isSuccessful == true) { + final syncedItem = item.copyWith(unSyncedData: false); + await _db.insertItem(syncedItem); + } else { + break; + } + } + } catch (e) { + // log('Error updating sync states: $e'); + } finally { + updatingSyncStatus = false; + } + } + void _init() { cleanupTemporaryFiles(); ref.listen( @@ -67,25 +103,29 @@ class SyncNotifier extends StateNotifier { (previous, next) { if (previous?.id != next?.id) { if (next?.id != null) { - _initializeQueryStream(); + _initializeQueryStream(id: next!.id); } } }, ); - _initializeQueryStream(); - + ref.listen(connectivityStatusProvider, (_, next) { + if (next != ConnectionState.offline) { + updateSyncStates(); + } + }); migrateFromIsar(); } - void _initializeQueryStream() async { - final userId = ref.read(userProvider)?.id; - if (userId == null) return; + void _initializeQueryStream({String? id}) async { + final userId = id ?? ref.read(userProvider)?.id; _subscription?.cancel(); + state = state.copyWith(items: []); - final queryStream = db.getParentItems.watch(); + if (userId == null) return; - final initItems = await db.getParentItems.get(); + final queryStream = _db.getParentItems.watch().distinct(); + final initItems = await _db.getParentItems.get(); state = state.copyWith(items: initItems); @@ -123,8 +163,6 @@ class SyncNotifier extends StateNotifier { } } - StreamSubscription>? _subscription; - late final JellyService api = ref.read(jellyApiProvider); String? get _savePath => !kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS) @@ -163,21 +201,25 @@ class SyncNotifier extends StateNotifier { super.dispose(); } - Future refresh() async { - state = state.copyWith(items: (await db.getParentItems.get())); + Future refresh() async => state = state.copyWith(items: (await _db.getParentItems.get())); + + Future> getNestedChildren(SyncedItem item) async => _db.getNestedChildren(item); + + Future> getChildren(String parentId) async => await _db.getChildren(parentId).get(); + + Future> getSiblings(SyncedItem syncedItem) async { + if (syncedItem.parentId == null) return []; + return getChildren(syncedItem.parentId!); } - Future> getNestedChildren(SyncedItem item) async => db.getNestedChildren(item); - - Future> getChildren(SyncedItem root) async => await db.getChildren(root.id).get(); - - Future getSyncedItem(ItemBaseModel? item) async { - final id = item?.id; + Future getSyncedItem(String? id) async { if (id == null) return null; - return await db.getItem(id).getSingleOrNull(); + return await _db.getItem(id).getSingleOrNull(); } - Future getParentItem(String id) async => await db.getParent(id).getSingleOrNull(); + Stream watchItem(String id) => _db.getItem(id).watchSingleOrNull(); + + Future getParentItem(String id) async => await _db.getParent(id).getSingleOrNull(); Future refreshSyncItem(SyncedItem item) async { List itemsToSync = await getNestedChildren(item); @@ -196,7 +238,7 @@ class SyncNotifier extends StateNotifier { final itemModel = ItemBaseModel.fromBaseDto(itemResponse.bodyOrThrow, ref); - final syncedParent = await db.getItem(itemToSync.parentId ?? "").getSingleOrNull(); + final syncedParent = await _db.getItem(itemToSync.parentId ?? "").getSingleOrNull(); SyncedItem newSyncedItem = await _syncItemData(syncedParent, itemModel, itemResponse.bodyOrThrow); @@ -217,7 +259,7 @@ class SyncNotifier extends StateNotifier { } } - await db.insertMultipleEntries(newItems); + await _db.insertMultipleEntries(newItems); return parentItem; } @@ -235,7 +277,9 @@ class SyncNotifier extends StateNotifier { ref.read(clientSettingsProvider.notifier).setSyncPath(selectedDirectory); } - fladderSnackbar(context, title: context.localized.syncAddItemForSyncing(item.detailedName(context) ?? "Unknown")); + if (context.mounted) { + 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), @@ -254,7 +298,7 @@ class SyncNotifier extends StateNotifier { } void viewDatabase(BuildContext context) => - Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (context) => DriftDbViewer(db))); + Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (context) => DriftDbViewer(_db))); Future removeSync(BuildContext context, SyncedItem? item) async { try { @@ -273,7 +317,7 @@ class SyncNotifier extends StateNotifier { await ref.read(backgroundDownloaderProvider).cancelTaskWithId(item.taskId!); } - await db.deleteAllItems([...nestedChildren, item]); + await _db.deleteAllItems([...nestedChildren, item]); for (var i = 0; i < nestedChildren.length; i++) { final element = nestedChildren[i]; @@ -291,8 +335,7 @@ class SyncNotifier extends StateNotifier { return true; } catch (e) { - log('Error deleting synced item'); - log(e.toString()); + log('Error deleting synced item ${e.toString()}'); state = state.copyWith(items: state.items.map((e) => e.copyWith(markedForDelete: false)).toList()); fladderSnackbar(context, title: context.localized.syncRemoveUnableToDeleteItem); return false; @@ -395,7 +438,16 @@ class SyncNotifier extends StateNotifier { return data?.copyWith(path: fileName); } - Future updateItem(SyncedItem syncedItem) async => db.insertItem(syncedItem); + Future updateItem(SyncedItem item) async { + SyncedItem syncedItem = item; + try { + await ref.read(jellyApiProvider).userItemsItemIdUserDataPost(itemId: syncedItem.id, body: syncedItem.userData); + } catch (e) { + log('Error updating item: ${syncedItem.id}'); + syncedItem = syncedItem.copyWith(unSyncedData: true); + } + return _db.insertItem(syncedItem); + } Future deleteFullSyncFiles(SyncedItem syncedItem, DownloadTask? task) async { await syncedItem.deleteDatFiles(ref); @@ -504,15 +556,81 @@ class SyncNotifier extends StateNotifier { return null; } - Future clear() async { - await mainDirectory.delete(recursive: true); - await db.clearDatabase(); + Future removeAllSyncedData() async { + if (await mainDirectory.exists()) { + await mainDirectory.delete(recursive: true); + } + await _db.close(); + await _db.clearDatabase(); + _db = AppDatabase(ref); state = state.copyWith(items: []); } - Future setup() async { - state = state.copyWith(items: []); - _init(); + Future updatePlaybackPosition({String? itemId, required Duration position}) async { + if (itemId == null) return; + + final syncedItem = await _db.getItem(itemId).getSingleOrNull(); + if (syncedItem == null) return; + + final item = syncedItem.itemModel; + if (item == null) return; + + final progress = position.inMilliseconds / (item.overview.runTime?.inMilliseconds ?? 0) * 100; + + final updatedItem = syncedItem.copyWith( + userData: syncedItem.userData?.copyWith( + playbackPositionTicks: position.toRuntimeTicks, + progress: progress, + played: UserData.isPlayed(position, item.overview.runTime ?? Duration.zero), + ), + ); + await _db.insertItem(updatedItem); + } + + Future updatePlayedItem(String? itemId, + {DateTime? datePlayed, required bool played, bool responseSuccessful = false}) async { + if (itemId == null) return; + + final syncedItem = _db.getItem(itemId).getSingleOrNull(); + syncedItem.then((item) async { + if (item == null) return; + final updatedUserData = item.userData?.copyWith( + played: played, + playbackPositionTicks: 0, + progress: 0.0, + lastPlayed: datePlayed ?? DateTime.now().toUtc(), + ); + SyncedItem updatedItem = item.copyWith(userData: updatedUserData, unSyncedData: !responseSuccessful); + + List children = []; + final shouldUpdateChildren = {FladderItemType.series, FladderItemType.season}.contains(item.itemModel?.type); + if (shouldUpdateChildren) { + // Update child items with the same played status, jellyfin server does this was well + // when marking a series or season as played + children = (await getNestedChildren(item)) + .map((e) => e.copyWith( + userData: e.userData?.copyWith( + played: played, + playbackPositionTicks: 0, + progress: 0.0, + ), + )) + .toList(); + } + await _db.insertMultipleEntries([updatedItem, ...children]); + }); + } + + Future updateFavoriteItem(String? itemId, {required bool isFavorite, bool responseSuccessful = false}) async { + if (itemId == null) return; + + final syncedItem = _db.getItem(itemId).getSingleOrNull(); + syncedItem.then((item) async { + if (item == null) return; + final updatedUserData = item.userData?.copyWith(isFavourite: isFavorite); + final updatedItem = item.copyWith(userData: updatedUserData, unSyncedData: !responseSuccessful); + await _db.insertItem(updatedItem); + }); } } @@ -520,14 +638,14 @@ extension SyncNotifierHelpers on SyncNotifier { Future createSyncItem(BaseItemDto response, {SyncedItem? parent}) async { final ItemBaseModel item = ItemBaseModel.fromBaseDto(response, ref); - final existingSyncedItem = await getSyncedItem(item); + final existingSyncedItem = await getSyncedItem(item.id); if (existingSyncedItem != null) return existingSyncedItem; SyncedItem syncItem = await _syncItemData(parent, item, response); if (parent == null) { - await db.insertItem(syncItem); + await _db.insertItem(syncItem); } return syncItem.copyWith( @@ -573,7 +691,7 @@ extension SyncNotifierHelpers on SyncNotifier { if (!syncItem.directory.existsSync()) return null; - await db.insertItem(syncItem); + await _db.insertItem(syncItem); await syncFile(syncItem, skipDownload); @@ -665,7 +783,7 @@ extension SyncNotifierHelpers on SyncNotifier { } } - await db.insertMultipleEntries(newItems); + await _db.insertMultipleEntries(newItems); for (var i = 0; i < itemsToDownload.length; i++) { final item = itemsToDownload[i]; diff --git a/lib/screens/details_screens/movie_detail_screen.dart b/lib/screens/details_screens/movie_detail_screen.dart index d76c65f..bf65d2f 100644 --- a/lib/screens/details_screens/movie_detail_screen.dart +++ b/lib/screens/details_screens/movie_detail_screen.dart @@ -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/item_base_model.dart'; import 'package:fladder/providers/items/movies_details_provider.dart'; diff --git a/lib/screens/metadata/info_screen.dart b/lib/screens/metadata/info_screen.dart index c5eabed..b73761c 100644 --- a/lib/screens/metadata/info_screen.dart +++ b/lib/screens/metadata/info_screen.dart @@ -63,24 +63,19 @@ class ItemInfoScreenState extends ConsumerState { child: Column( children: [ Padding( - padding: const EdgeInsets.all(16), - child: Text( - widget.item.name, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - const Opacity(opacity: 0.3, child: Divider()), - Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( mainAxisSize: MainAxisSize.max, + spacing: 6, children: [ + Text( + widget.item.name, + style: Theme.of(context).textTheme.titleLarge, + ), const Spacer(), - const SizedBox(width: 6), IconButton( onPressed: () => context.copyToClipboard(info.model.toString()), icon: const Icon(Icons.copy_all_rounded)), - const SizedBox(width: 6), IconButton( onPressed: () => ref.read(provider.notifier).getItemInformation(widget.item), icon: const Icon(IconsaxPlusLinear.refresh), @@ -88,6 +83,7 @@ class ItemInfoScreenState extends ConsumerState { ], ), ), + const Opacity(opacity: 0.3, child: Divider()), ], ), ), diff --git a/lib/screens/settings/client_sections/client_settings_download.dart b/lib/screens/settings/client_sections/client_settings_download.dart index 73ddde8..d9fd657 100644 --- a/lib/screens/settings/client_sections/client_settings_download.dart +++ b/lib/screens/settings/client_sections/client_settings_download.dart @@ -96,7 +96,7 @@ List buildClientSettingsDownload(BuildContext context, WidgetRef ref, Fu context.localized.downloadsClearTitle, context.localized.downloadsClearDesc, (context) async { - await ref.read(syncProvider.notifier).clear(); + await ref.read(syncProvider.notifier).removeAllSyncedData(); setState(() {}); Navigator.of(context).pop(); }, diff --git a/lib/screens/shared/adaptive_dialog.dart b/lib/screens/shared/adaptive_dialog.dart index 49fdef8..1e2439f 100644 --- a/lib/screens/shared/adaptive_dialog.dart +++ b/lib/screens/shared/adaptive_dialog.dart @@ -16,11 +16,8 @@ Future showDialogAdaptive( return showDialog( context: context, useSafeArea: false, - builder: (context) => Padding( - padding: MediaQuery.paddingOf(context), - child: Dialog.fullscreen( - child: builder(context), - ), + builder: (context) => Dialog.fullscreen( + child: builder(context), ), ); } diff --git a/lib/screens/shared/default_title_bar.dart b/lib/screens/shared/default_title_bar.dart index 6375cac..aefdca7 100644 --- a/lib/screens/shared/default_title_bar.dart +++ b/lib/screens/shared/default_title_bar.dart @@ -1,12 +1,14 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide ConnectionState; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart'; import 'package:fladder/providers/arguments_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; +import 'package:fladder/widgets/shared/offline_banner.dart'; class DefaultTitleBar extends ConsumerStatefulWidget { final String? label; @@ -36,9 +38,11 @@ class _DefaultTitleBarState extends ConsumerState with WindowLi @override Widget build(BuildContext context) { if (ref.watch(argumentsStateProvider.select((value) => value.htpcMode))) return const SizedBox.shrink(); - final brightness = widget.brightness ?? Theme.of(context).brightness; - final iconColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65); - final surfaceColor = Theme.of(context).colorScheme.surface; + final theme = Theme.of(context); + final brightness = widget.brightness ?? theme.brightness; + final iconColor = theme.colorScheme.onSurface.withValues(alpha: 0.65); + final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline)); + final surfaceColor = theme.colorScheme.surface; return MouseRegion( onEnter: (event) => setState(() => hovering = true), onExit: (event) => setState(() => hovering = false), @@ -46,147 +50,160 @@ class _DefaultTitleBarState extends ConsumerState with WindowLi duration: const Duration(milliseconds: 250), decoration: BoxDecoration( gradient: LinearGradient( - colors: [ - surfaceColor.withValues(alpha: hovering ? 0.7 : 0), - surfaceColor.withValues(alpha: 0), - ], + colors: isOffline + ? [ + theme.colorScheme.errorContainer.withValues(alpha: 0.8), + theme.colorScheme.errorContainer.withValues(alpha: 0.25), + ] + : [ + surfaceColor.withValues(alpha: hovering ? 0.7 : 0), + surfaceColor.withValues(alpha: 0), + ], begin: Alignment.topCenter, end: Alignment.bottomCenter, )), height: widget.height, child: kIsWeb ? const SizedBox.shrink() - : switch (AdaptiveLayout.of(context).platform) { - TargetPlatform.android || TargetPlatform.iOS => SizedBox(height: MediaQuery.paddingOf(context).top), - TargetPlatform.windows || TargetPlatform.linux => Row( - children: [ - Expanded( - child: Container( - color: Colors.black.withValues(alpha: 0), - child: DragToMoveArea( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.max, - children: [ - Container( - padding: const EdgeInsets.only(left: 16), - child: DefaultTextStyle( - style: TextStyle( - color: iconColor, - fontSize: 14, - ), - child: Text(widget.label ?? ""), - ), - ), - ], - ), - ), - ), - ), - Container( - decoration: BoxDecoration(boxShadow: [ - BoxShadow( - color: surfaceColor.withValues(alpha: 0.15), - blurRadius: 32, - spreadRadius: 10, - offset: const Offset(8, -6), - ), - ]), + : Stack( + fit: StackFit.expand, + children: [ + switch (AdaptiveLayout.of(context).platform) { + TargetPlatform.android || TargetPlatform.iOS => SizedBox(height: MediaQuery.paddingOf(context).top), + TargetPlatform.windows || TargetPlatform.linux => Container( child: Row( children: [ - FutureBuilder>(future: Future.microtask(() async { - final isMinimized = await windowManager.isMinimized(); - return [isMinimized]; - }), builder: (context, snapshot) { - final isMinimized = snapshot.data?.firstOrNull ?? false; - return IconButton( - style: IconButton.styleFrom( - hoverColor: brightness == Brightness.light - ? Colors.black.withValues(alpha: 0.1) - : Colors.white.withValues(alpha: 0.2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))), - onPressed: () async { - fullScreenHelper.closeFullScreen(ref); - if (isMinimized) { - windowManager.restore(); - } else { - windowManager.minimize(); - } - }, - icon: Transform.translate( - offset: const Offset(0, -2), - child: Icon( - Icons.minimize_rounded, - color: iconColor, - size: 20, + Expanded( + child: Container( + color: Colors.black.withValues(alpha: 0), + child: DragToMoveArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + Container( + padding: const EdgeInsets.only(left: 16), + child: DefaultTextStyle( + style: TextStyle( + color: iconColor, + fontSize: 14, + ), + child: Text(widget.label ?? ""), + ), + ), + ], ), ), - ); - }), - FutureBuilder>( - future: Future.microtask(() async { - final isMaximized = await windowManager.isMaximized(); - return [isMaximized]; - }), - builder: (BuildContext context, AsyncSnapshot> snapshot) { - final maximized = snapshot.data?.firstOrNull ?? false; - return IconButton( - style: IconButton.styleFrom( - hoverColor: brightness == Brightness.light - ? Colors.black.withValues(alpha: 0.1) - : Colors.white.withValues(alpha: 0.2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), - ), - onPressed: () async { - fullScreenHelper.closeFullScreen(ref); - if (maximized) { - await windowManager.unmaximize(); - return; - } - if (!maximized) { - await windowManager.maximize(); - } else { - await windowManager.unmaximize(); - } - }, - icon: Transform.translate( - offset: const Offset(0, 0), - child: Icon( - maximized ? Icons.maximize_rounded : Icons.crop_square_rounded, - color: iconColor, - size: 19, - ), - ), - ); - }, - ), - IconButton( - style: IconButton.styleFrom( - hoverColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(2), - ), ), - onPressed: () async { - windowManager.close(); - }, - icon: Transform.translate( - offset: const Offset(0, -2), - child: Icon( - Icons.close_rounded, - color: iconColor, - size: 23, + ), + Container( + decoration: BoxDecoration(boxShadow: [ + BoxShadow( + color: surfaceColor.withValues(alpha: isOffline ? 0 : 0.5), + blurRadius: 32, + spreadRadius: 10, + offset: const Offset(8, -6), ), + ]), + child: Row( + children: [ + FutureBuilder>(future: Future.microtask(() async { + final isMinimized = await windowManager.isMinimized(); + return [isMinimized]; + }), builder: (context, snapshot) { + final isMinimized = snapshot.data?.firstOrNull ?? false; + return IconButton( + style: IconButton.styleFrom( + hoverColor: brightness == Brightness.light + ? Colors.black.withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))), + onPressed: () async { + fullScreenHelper.closeFullScreen(ref); + if (isMinimized) { + windowManager.restore(); + } else { + windowManager.minimize(); + } + }, + icon: Transform.translate( + offset: const Offset(0, -2), + child: Icon( + Icons.minimize_rounded, + color: iconColor, + size: 20, + ), + ), + ); + }), + FutureBuilder>( + future: Future.microtask(() async { + final isMaximized = await windowManager.isMaximized(); + return [isMaximized]; + }), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + final maximized = snapshot.data?.firstOrNull ?? false; + return IconButton( + style: IconButton.styleFrom( + hoverColor: brightness == Brightness.light + ? Colors.black.withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + ), + onPressed: () async { + fullScreenHelper.closeFullScreen(ref); + if (maximized) { + await windowManager.unmaximize(); + return; + } + if (!maximized) { + await windowManager.maximize(); + } else { + await windowManager.unmaximize(); + } + }, + icon: Transform.translate( + offset: const Offset(0, 0), + child: Icon( + maximized ? Icons.maximize_rounded : Icons.crop_square_rounded, + color: iconColor, + size: 19, + ), + ), + ); + }, + ), + IconButton( + style: IconButton.styleFrom( + hoverColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(2), + ), + ), + onPressed: () async { + windowManager.close(); + }, + icon: Transform.translate( + offset: const Offset(0, -2), + child: Icon( + Icons.close_rounded, + color: iconColor, + size: 23, + ), + ), + ), + ], ), ), ], ), ), - ], - ), - TargetPlatform.macOS => const SizedBox.shrink(), - _ => Text(widget.label ?? "Fladder"), - }, + TargetPlatform.macOS => const SizedBox.shrink(), + _ => Text(widget.label ?? "Fladder"), + }, + const OfflineBanner() + ], + ), ), ); } diff --git a/lib/screens/shared/fladder_snackbar.dart b/lib/screens/shared/fladder_snackbar.dart index 692cfdc..751711e 100644 --- a/lib/screens/shared/fladder_snackbar.dart +++ b/lib/screens/shared/fladder_snackbar.dart @@ -10,21 +10,26 @@ void fladderSnackbar( bool showCloseButton = false, Duration duration = const Duration(seconds: 3), }) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - title, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSecondary), - ), - clipBehavior: Clip.none, - showCloseIcon: showCloseButton, - duration: duration, - padding: const EdgeInsets.all(18), - action: action, - )); + try { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + title, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSecondary), + ), + clipBehavior: Clip.none, + showCloseIcon: showCloseButton, + duration: duration, + padding: const EdgeInsets.all(18), + action: action, + )); + } catch (e) { + // Handle the case where the context is not mounted or any other error + debugPrint("Error showing snackbar: $e"); + } } void fladderSnackbarResponse(BuildContext context, Response? response, {String? altTitle}) { diff --git a/lib/screens/shared/media/episode_posters.dart b/lib/screens/shared/media/episode_posters.dart index a259d2c..3caac9e 100644 --- a/lib/screens/shared/media/episode_posters.dart +++ b/lib/screens/shared/media/episode_posters.dart @@ -3,6 +3,7 @@ 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/screens/shared/flat_button.dart'; @@ -151,6 +152,7 @@ class EpisodePoster extends ConsumerWidget { child: const Icon(Icons.local_movies_outlined), ); bool episodeAvailable = episode.status == EpisodeStatus.available; + final syncedDetails = ref.watch(syncedItemProvider(episode)); return AspectRatio( aspectRatio: 1.76, child: Column( @@ -196,18 +198,18 @@ class EpisodePoster extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - ref.watch(syncedItemProvider(episode)).when( - error: (error, stackTrace) => const SizedBox.shrink(), - data: (syncedItem) { - if (syncedItem == null) { + switch (syncedDetails) { + AsyncValue(:final value) => Builder( + builder: (context) { + if (value == null) { return const SizedBox.shrink(); } return StatusCard( - child: SyncButton(item: episode, syncedItem: syncedItem), + child: SyncButton(item: episode, syncedItem: value), ); }, - loading: () => const SizedBox.shrink(), ), + }, if (episode.userData.isFavourite) const StatusCard( color: Colors.red, diff --git a/lib/screens/syncing/sync_button.dart b/lib/screens/syncing/sync_button.dart index 07e0e46..8586eeb 100644 --- a/lib/screens/syncing/sync_button.dart +++ b/lib/screens/syncing/sync_button.dart @@ -16,36 +16,36 @@ class SyncButton extends ConsumerWidget { @override 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; + return switch (nested) { + AsyncValue>(:final value) => Builder( + builder: (context) { + final download = ref.watch(syncDownloadStatusProvider(syncedItem, value ?? [])); + final status = download?.status ?? TaskStatus.notFound; + final progress = download?.progress ?? 0.0; - 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: 1.5, - color: status.color(context), - value: status == TaskStatus.running ? progress.clamp(0.0, 1.0) : 0, - ), - ), - ], - ); - }, - ); + 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: 1.5, + color: status.color(context), + value: status == TaskStatus.running ? progress.clamp(0.0, 1.0) : 0, + ), + ), + ], + ); + }, + ), + }; } } diff --git a/lib/screens/syncing/sync_item_details.dart b/lib/screens/syncing/sync_item_details.dart index 2641baf..a9addb9 100644 --- a/lib/screens/syncing/sync_item_details.dart +++ b/lib/screens/syncing/sync_item_details.dart @@ -230,7 +230,8 @@ class _SyncItemDetailsState extends ConsumerState { else if (baseItem?.parentBaseModel != null) ElevatedButton( onPressed: () async { - final parentItem = await ref.read(syncProvider.notifier).getSyncedItem(baseItem!.parentBaseModel); + final parentItem = + await ref.read(syncProvider.notifier).getSyncedItem(baseItem!.parentBaseModel.id); setState(() { if (parentItem != null) { syncedItem = parentItem; diff --git a/lib/screens/syncing/synced_screen.dart b/lib/screens/syncing/synced_screen.dart index 792fd95..a8331cf 100644 --- a/lib/screens/syncing/synced_screen.dart +++ b/lib/screens/syncing/synced_screen.dart @@ -56,8 +56,10 @@ class _SyncedScreenState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: padding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, spacing: 12, children: [ ElevatedButton( @@ -65,7 +67,7 @@ class _SyncedScreenState extends ConsumerState { child: const Text("View Database"), ), ElevatedButton( - onPressed: () => ref.read(syncProvider.notifier).db.clearDatabase(), + onPressed: () => ref.read(syncProvider.notifier).removeAllSyncedData(), child: const Text("Clear drift database"), ), ElevatedButton( diff --git a/lib/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart index ffbdad0..8c863db 100644 --- a/lib/screens/video_player/components/video_player_next_wrapper.dart +++ b/lib/screens/video_player/components/video_player_next_wrapper.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:screen_brightness/screen_brightness.dart'; import 'package:fladder/models/item_base_model.dart'; diff --git a/lib/theme.dart b/lib/theme.dart index 958f653..5b390af 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -34,9 +34,6 @@ class FladderTheme { static RoundedRectangleBorder get defaultShape => RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)); static RoundedRectangleBorder get largeShape => RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)); - static Color get darkBackgroundColor => const Color.fromARGB(255, 10, 10, 10); - static Color get lightBackgroundColor => const Color.fromARGB(237, 255, 255, 255); - static ThemeData theme(ColorScheme? colorScheme, DynamicSchemeVariant dynamicSchemeVariant) { final ColorScheme? scheme = generateDynamicColourSchemes(colorScheme, dynamicSchemeVariant); diff --git a/lib/util/item_base_model/item_base_model_extensions.dart b/lib/util/item_base_model/item_base_model_extensions.dart index a0a4780..2230ebd 100644 --- a/lib/util/item_base_model/item_base_model_extensions.dart +++ b/lib/util/item_base_model/item_base_model_extensions.dart @@ -110,7 +110,7 @@ extension ItemBaseModelExtensions on ItemBaseModel { syncAble && (canDownload ?? false); final downloadUrl = ref.read(userProvider.notifier).createDownloadUrl(this); - final syncedItemFuture = ref.read(syncProvider.notifier).getSyncedItem(this); + final syncedItemFuture = ref.read(syncProvider.notifier).getSyncedItem(id); return [ if (!exclude.contains(ItemActions.play)) if (playAble) @@ -185,9 +185,12 @@ extension ItemBaseModelExtensions on ItemBaseModel { ItemActionButton( icon: const Icon(IconsaxPlusLinear.eye), action: () async { - final userData = await ref.read(userProvider.notifier).markAsPlayed(true, id); - onUserDataChanged?.call(userData?.bodyOrThrow); - context.refreshData(); + try { + final userData = await ref.read(userProvider.notifier).markAsPlayed(true, id); + onUserDataChanged?.call(userData?.bodyOrThrow); + } finally { + context.refreshData(); + } }, label: Text(context.localized.markAsWatched), ), @@ -196,18 +199,24 @@ extension ItemBaseModelExtensions on ItemBaseModel { icon: const Icon(IconsaxPlusLinear.eye_slash), label: Text(context.localized.markAsUnwatched), action: () async { - final userData = await ref.read(userProvider.notifier).markAsPlayed(false, id); - onUserDataChanged?.call(userData?.bodyOrThrow); - context.refreshData(); + try { + final userData = await ref.read(userProvider.notifier).markAsPlayed(false, id); + onUserDataChanged?.call(userData?.bodyOrThrow); + } finally { + context.refreshData(); + } }, ), if (!exclude.contains(ItemActions.setFavorite)) ItemActionButton( icon: Icon(userData.isFavourite ? IconsaxPlusLinear.heart_remove : IconsaxPlusLinear.heart_add), action: () async { - final newData = await ref.read(userProvider.notifier).setAsFavorite(!userData.isFavourite, id); - onUserDataChanged?.call(newData?.bodyOrThrow); - context.refreshData(); + try { + final newData = await ref.read(userProvider.notifier).setAsFavorite(!userData.isFavourite, id); + onUserDataChanged?.call(newData?.bodyOrThrow); + } finally { + context.refreshData(); + } }, label: Text(userData.isFavourite ? context.localized.removeAsFavorite : context.localized.addAsFavorite), ), diff --git a/lib/util/item_base_model/play_item_helpers.dart b/lib/util/item_base_model/play_item_helpers.dart index b00c264..66ca7f7 100644 --- a/lib/util/item_base_model/play_item_helpers.dart +++ b/lib/util/item_base_model/play_item_helpers.dart @@ -101,9 +101,11 @@ Future _playVideo( if (AdaptiveLayout.of(context).isDesktop) { fullScreenHelper.closeFullScreen(ref); } + if (context.mounted) { - context.refreshData(); + await context.refreshData(); } + onPlayerExit?.call(); } } @@ -138,7 +140,7 @@ extension BookBaseModelExtension on BookModel? { ); parentContext?.refreshData(); if (context.mounted) { - context.refreshData(); + await context.refreshData(); } } } @@ -176,7 +178,7 @@ extension PhotoAlbumExtension on PhotoAlbumModel? { ), ); if (context.mounted) { - context.refreshData(); + await context.refreshData(); } return; } diff --git a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart index 6a514e1..82f23ff 100644 --- a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart +++ b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart @@ -54,7 +54,7 @@ class _CurrentlyPlayingBarState extends ConsumerState { } } if (context.mounted) { - context.refreshData(); + await context.refreshData(); } } diff --git a/lib/widgets/navigation_scaffold/navigation_scaffold.dart b/lib/widgets/navigation_scaffold/navigation_scaffold.dart index d8078e4..b36ecec 100644 --- a/lib/widgets/navigation_scaffold/navigation_scaffold.dart +++ b/lib/widgets/navigation_scaffold/navigation_scaffold.dart @@ -1,9 +1,10 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide ConnectionState; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/media_playback_model.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/views_provider.dart'; import 'package:fladder/routes/auto_router.dart'; @@ -15,7 +16,9 @@ import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.d import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart'; import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart'; import 'package:fladder/widgets/navigation_scaffold/components/navigation_drawer.dart'; +import 'package:fladder/widgets/shared/animated_visibility.dart'; import 'package:fladder/widgets/shared/hide_on_scroll.dart'; +import 'package:fladder/widgets/shared/offline_banner.dart'; class NavigationScaffold extends ConsumerStatefulWidget { final String? currentRouteName; @@ -55,17 +58,22 @@ class _NavigationScaffoldState extends ConsumerState { final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state)); final showPlayerBar = playerState == VideoPlayerState.minimized; - final isDesktop = AdaptiveLayout.of(context).isDesktop; + final isDesktop = AdaptiveLayout.of(context).isDesktop || kIsWeb; final mediaQuery = MediaQuery.of(context); + final theme = Theme.of(context); final paddingOf = mediaQuery.padding; final viewPaddingOf = mediaQuery.viewPadding; - final bottomPadding = isDesktop || kIsWeb ? 12.0 : paddingOf.bottom; - final bottomViewPadding = isDesktop || kIsWeb ? 12.0 : viewPaddingOf.bottom; + final bottomPadding = isDesktop ? 12.0 : paddingOf.bottom; + final bottomViewPadding = isDesktop ? 12.0 : viewPaddingOf.bottom; final isHomeScreen = currentIndex != -1; + final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline)); + + final offlineMessageHeight = isOffline && !isDesktop ? 12 : 0; + return PopScope( canPop: currentIndex == 0, onPopInvokedWithResult: (didPop, result) { @@ -80,9 +88,13 @@ class _NavigationScaffoldState extends ConsumerState { child: MediaQuery( data: mediaQuery.copyWith( padding: paddingOf.copyWith( - bottom: showPlayerBar ? floatingPlayerHeight(context) + 12 + bottomPadding : bottomPadding), + top: mediaQuery.padding.top + offlineMessageHeight, + bottom: showPlayerBar ? floatingPlayerHeight(context) + 12 + bottomPadding : bottomPadding, + ), viewPadding: viewPaddingOf.copyWith( - bottom: showPlayerBar ? floatingPlayerHeight(context) + bottomViewPadding : bottomViewPadding), + top: mediaQuery.viewPadding.top, + bottom: showPlayerBar ? floatingPlayerHeight(context) + bottomViewPadding : bottomViewPadding, + ), ), //Builder to correctly apply new padding child: Builder(builder: (context) { @@ -104,27 +116,29 @@ class _NavigationScaffoldState extends ConsumerState { currentLocation: currentLocation, ) : null, - bottomNavigationBar: isHomeScreen && AdaptiveLayout.viewSizeOf(context) == ViewSize.phone - ? HideOnScroll( - controller: AdaptiveLayout.scrollOf(context), - forceHide: !homeRoutes.any((element) => element.name.contains(currentLocation)), - child: NestedBottomAppBar( - child: SizedBox( - height: 65, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: widget.destinations - .map( - (destination) => destination.toNavigationButton( - widget.currentRouteName == destination.route?.routeName, false, false), - ) - .toList(), - ), - ), + bottomNavigationBar: AnimatedVisibility( + visible: (isHomeScreen && AdaptiveLayout.viewSizeOf(context) == ViewSize.phone), + duration: const Duration(milliseconds: 250), + child: HideOnScroll( + controller: AdaptiveLayout.scrollOf(context), + forceHide: !homeRoutes.any((element) => element.name.contains(currentLocation)), + child: NestedBottomAppBar( + child: SizedBox( + height: 65, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.destinations + .map( + (destination) => destination.toNavigationButton( + widget.currentRouteName == destination.route?.routeName, false, false), + ) + .toList(), ), - ) - : null, + ), + ), + ), + ), body: widget.nestedChild != null ? NavigationBody( child: widget.nestedChild!, @@ -147,7 +161,32 @@ class _NavigationScaffoldState extends ConsumerState { child: showPlayerBar ? const FloatingPlayerBar() : const SizedBox.shrink(), ), ), - ) + ), + if (!AdaptiveLayout.of(context).isDesktop) + Align( + alignment: Alignment.topCenter, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: isOffline ? 1 : 0, + child: Container( + height: kToolbarHeight + offlineMessageHeight, + alignment: Alignment.bottomCenter, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + theme.colorScheme.errorContainer.withValues(alpha: 0.8), + theme.colorScheme.errorContainer.withValues(alpha: 0.25), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + )), + child: const Padding( + padding: EdgeInsets.only(bottom: 8), + child: OfflineBanner(), + ), + ), + ), + ), ], ), ); diff --git a/lib/widgets/shared/animated_visibility.dart b/lib/widgets/shared/animated_visibility.dart new file mode 100644 index 0000000..cd05fe5 --- /dev/null +++ b/lib/widgets/shared/animated_visibility.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class AnimatedVisibility extends StatelessWidget { + final Widget? child; + final bool visible; + final Duration duration; + const AnimatedVisibility( + {required this.child, required this.visible, this.duration = const Duration(milliseconds: 250), super.key}); + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + duration: duration, + opacity: visible ? 1 : 0, + child: IgnorePointer( + ignoring: !visible, + child: SizedBox( + height: visible ? null : 16, + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/shared/offline_banner.dart b/lib/widgets/shared/offline_banner.dart new file mode 100644 index 0000000..aaa0cf4 --- /dev/null +++ b/lib/widgets/shared/offline_banner.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart' hide ConnectionState; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/connectivity_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; + +class OfflineBanner extends ConsumerWidget { + const OfflineBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline)); + final theme = Theme.of(context); + return AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: isOffline ? 1 : 0, + child: IgnorePointer( + child: Row( + spacing: 12, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + IconsaxPlusLinear.cloud_cross, + color: theme.colorScheme.onErrorContainer, + size: 20, + ), + Text( + context.localized.offline, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/shared/selectable_icon_button.dart b/lib/widgets/shared/selectable_icon_button.dart index 277e9d1..1dee0e0 100644 --- a/lib/widgets/shared/selectable_icon_button.dart +++ b/lib/widgets/shared/selectable_icon_button.dart @@ -48,11 +48,11 @@ class _SelectableIconButtonState extends ConsumerState { setState(() => loading = true); try { await widget.onPressed(); - if (context.mounted) await context.refreshData(); } catch (e) { log(e.toString()); } finally { setState(() => loading = false); + if (context.mounted) await context.refreshData(); } }, child: Padding( diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 05f068a..cb3d2f4 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -244,10 +244,10 @@ class MediaControlsWrapper extends BaseAudioHandler { final position = _player?.lastState.position; final totalDuration = _player?.lastState.duration; - //Small delay so we don't post right after playback/progress update + // //Small delay so we don't post right after playback/progress update await Future.delayed(const Duration(seconds: 1)); - ref.read(playBackModel)?.playbackStopped(position ?? Duration.zero, totalDuration, ref); + await ref.read(playBackModel)?.playbackStopped(position ?? Duration.zero, totalDuration, ref); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(position: Duration.zero)); smtc?.setPlaybackStatus(PlaybackStatus.stopped);