feat: Sync offline/online playback when able (#431)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-08-03 13:35:56 +02:00 committed by GitHub
parent 15ac3566e2
commit 092836328f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1002 additions and 497 deletions

View file

@ -1291,5 +1291,6 @@
"syncDeleteAll": "Delete all files", "syncDeleteAll": "Delete all files",
"syncAllFiles": "Sync all files", "syncAllFiles": "Sync all files",
"usePostersForLibraryIconsTitle": "Show posters for library icons", "usePostersForLibraryIconsTitle": "Show posters for library icons",
"usePostersForLibraryIconsDesc": "Show posters instead of icons for libraries" "usePostersForLibraryIconsDesc": "Show posters instead of icons for libraries",
"offline": "Offline"
} }

View file

@ -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); 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<UserData?> data) { static UserData? determineLastUserData(List<UserData?> data) {
return data.where((data) => data != null).reduce((a, b) { return data.where((data) => data != null).reduce((a, b) {
final aDate = a?.lastPlayed; final aDate = a?.lastPlayed;

View file

@ -66,35 +66,36 @@ class OfflinePlaybackModel extends PlaybackModel {
@override @override
Future<PlaybackModel?> playbackStopped(Duration position, Duration? totalDuration, Ref ref) async { Future<PlaybackModel?> 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; return null;
} }
@override @override
Future<PlaybackModel?> updatePlaybackPosition(Duration position, bool isPlaying, Ref ref) async { Future<PlaybackModel?> updatePlaybackPosition(Duration position, bool isPlaying, Ref ref) async {
final progress = position.inMilliseconds / (item.overview.runTime?.inMilliseconds ?? 0) * 100; 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( final newItem = syncedItem.copyWith(
userData: syncedItem.userData?.copyWith( userData: userData,
playbackPositionTicks: position.toRuntimeTicks,
progress: progress,
played: isPlayed(position, item.overview.runTime ?? Duration.zero),
),
); );
await ref.read(syncProvider.notifier).updateItem(newItem); await ref.read(syncProvider.notifier).updateItem(newItem);
return this; return null;
}
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;
} }
@override @override

View file

@ -1,6 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart' hide ConnectionState;
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:chopper/chopper.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/models/video_stream_model.dart';
import 'package:fladder/profiles/default_profile.dart'; import 'package:fladder/profiles/default_profile.dart';
import 'package:fladder/providers/api_provider.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/service_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/sync_provider.dart';
@ -132,7 +133,7 @@ class PlaybackModelHelper {
await _createOfflinePlaybackModel( await _createOfflinePlaybackModel(
newItem, newItem,
null, null,
await ref.read(syncProvider.notifier).getSyncedItem(newItem), await ref.read(syncProvider.notifier).getSyncedItem(newItem.id),
oldModel: currentModel, oldModel: currentModel,
); );
if (newModel == null) return null; if (newModel == null) return null;
@ -147,11 +148,12 @@ class PlaybackModelHelper {
PlaybackModel? oldModel, PlaybackModel? oldModel,
}) async { }) async {
final ItemBaseModel? syncedItemModel = syncedItem?.itemModel; 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 children = await ref.read(syncProvider.notifier).getSiblings(syncedItem);
final syncedItems = children.where((element) => element.videoFile.existsSync()).toList(); final syncedItems =
final itemQueue = syncedItems.map((e) => e.createItemModel(ref)); children.where((element) => element.videoFile.existsSync() && element.id != syncedItem.id).toList();
final itemQueue = syncedItems.map((e) => e.itemModel).nonNulls;
return OfflinePlaybackModel( return OfflinePlaybackModel(
item: syncedItemModel, item: syncedItemModel,
@ -173,11 +175,11 @@ class PlaybackModelHelper {
bool showPlaybackOptions = false, bool showPlaybackOptions = false,
Duration? startPosition, Duration? startPosition,
}) async { }) async {
if (item == null) return null;
final userId = ref.read(userProvider)?.id;
if (userId?.isEmpty == true) return null;
try { 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 queue = oldModel?.queue ?? libraryQueue ?? await collectQueue(item);
final firstItemToPlay = switch (item) { final firstItemToPlay = switch (item) {
@ -191,7 +193,7 @@ class PlaybackModelHelper {
if (fullItem == null) return null; 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; final firstItemIsSynced = syncedItem != null && syncedItem.status == TaskStatus.complete;
@ -201,7 +203,9 @@ class PlaybackModelHelper {
if (firstItemIsSynced) PlaybackType.offline, 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( final playbackType = await showPlaybackTypeSelection(
context: context, context: context,
options: options, options: options,
@ -241,16 +245,7 @@ class PlaybackModelHelper {
); );
} }
} catch (e) { } catch (e) {
log(e.toString()); log("Error creating playback model: ${e.toString()}");
SyncedItem? syncedItem = await ref.read(syncProvider.notifier).getSyncedItem(item);
if (syncedItem != null) {
return await _createOfflinePlaybackModel(
item,
item.streamModel,
syncedItem,
oldModel: oldModel,
);
}
return null; return null;
} }
} }

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/models/video_stream_model.dart';
import 'package:fladder/screens/shared/adaptive_dialog.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
Future<PlaybackType?> showPlaybackTypeSelection({ Future<PlaybackType?> showPlaybackTypeSelection({
@ -10,17 +9,18 @@ Future<PlaybackType?> showPlaybackTypeSelection({
}) async { }) async {
PlaybackType? playbackType; PlaybackType? playbackType;
await showDialogAdaptive( await showDialog(
context: context, context: context,
builder: (context) { useSafeArea: false,
return PlaybackDialogue( builder: (context) => Dialog(
child: PlaybackDialogue(
options: options, options: options,
onClose: (type) { onClose: (type) {
playbackType = type; playbackType = type;
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
); ),
}, ),
); );
return playbackType; return playbackType;
} }

View file

@ -1,8 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:fladder/models/item_base_model.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'; part 'database_item.g.dart';
const _databseName = 'syncedDatabase';
@TableIndex(name: 'database_id', columns: {#userId, #id}) @TableIndex(name: 'database_id', columns: {#userId, #id})
class DatabaseItems extends Table { class DatabaseItems extends Table {
TextColumn get userId => text().withLength(min: 1)(); TextColumn get userId => text().withLength(min: 1)();
TextColumn get id => 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 sortName => text().nullable()();
TextColumn get parentId => text().nullable()(); TextColumn get parentId => text().nullable()();
TextColumn get path => text().nullable()(); TextColumn get path => text().nullable()();
@ -32,6 +36,7 @@ class DatabaseItems extends Table {
TextColumn get images => text().nullable()(); TextColumn get images => text().nullable()();
TextColumn get chapters => text().nullable()(); TextColumn get chapters => text().nullable()();
TextColumn get subtitles => text().nullable()(); TextColumn get subtitles => text().nullable()();
BoolColumn get unSyncedData => boolean().withDefault(const Constant(false))();
TextColumn get userData => text().nullable()(); TextColumn get userData => text().nullable()();
@override @override
@ -47,14 +52,15 @@ class AppDatabase extends _$AppDatabase {
String get userId => ref.read(userProvider.select((value) => value?.id ?? "")); String get userId => ref.read(userProvider.select((value) => value?.id ?? ""));
@override @override
int get schemaVersion => 2; int get schemaVersion => 1;
Future<void> clearDatabase() { Future<void> clearDatabase() async {
return transaction(() async { final dbPath = await getApplicationSupportDirectory();
for (final table in allTables) { final dbFile = File(p.join(dbPath.path, '$_databseName.sqlite'));
await delete(table).go();
} if (await dbFile.exists()) {
}); await dbFile.delete(recursive: true);
}
} }
Selectable<SyncedItem> getItem(String id) => Selectable<SyncedItem> getItem(String id) =>
@ -69,6 +75,10 @@ class AppDatabase extends _$AppDatabase {
..orderBy([(t) => OrderingTerm(expression: t.sortName)])) ..orderBy([(t) => OrderingTerm(expression: t.sortName)]))
.map(databaseConverter); .map(databaseConverter);
Selectable<SyncedItem> get getAllItems => ((select(databaseItems)..where((tbl) => tbl.userId.equals(userId)))
..orderBy([(t) => OrderingTerm(expression: t.sortName)]))
.map(databaseConverter);
Selectable<SyncedItem> getChildren(String parentId) => Selectable<SyncedItem> getChildren(String parentId) =>
((select(databaseItems)..where((tbl) => (tbl.parentId.equals(parentId) & tbl.userId.equals(userId)))) ((select(databaseItems)..where((tbl) => (tbl.parentId.equals(parentId) & tbl.userId.equals(userId))))
..orderBy([(t) => OrderingTerm(expression: t.sortName)])) ..orderBy([(t) => OrderingTerm(expression: t.sortName)]))
@ -90,11 +100,9 @@ class AppDatabase extends _$AppDatabase {
if (itemType == null) return []; if (itemType == null) return [];
final int maxDepth = switch (itemType) { final int maxDepth = switch (itemType) {
FladderItemType.episode => 0,
FladderItemType.movie => 0,
FladderItemType.season => 1, FladderItemType.season => 1,
FladderItemType.series => 2, FladderItemType.series => 2,
_ => 1, _ => 0,
}; };
final all = <SyncedItem>[]; final all = <SyncedItem>[];
@ -151,6 +159,7 @@ class AppDatabase extends _$AppDatabase {
chapters: Value(jsonEncode(item.fChapters.map((e) => e.toJson()).toList())), chapters: Value(jsonEncode(item.fChapters.map((e) => e.toJson()).toList())),
subtitles: Value(jsonEncode(item.subtitles.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), 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() ? (jsonDecode(dataItem.subtitles!) as List).map((e) => SubStreamModel.fromJson(e)).toList()
: [], : [],
userData: dataItem.userData != null ? UserData.fromJson(jsonDecode(dataItem.userData!)) : null, userData: dataItem.userData != null ? UserData.fromJson(jsonDecode(dataItem.userData!)) : null,
unSyncedData: dataItem.unSyncedData,
); );
return syncedItem.copyWith( return syncedItem.copyWith(
@ -185,34 +195,11 @@ class AppDatabase extends _$AppDatabase {
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
return driftDatabase( return driftDatabase(
name: 'syncedDatabase', name: _databseName,
native: const DriftNativeOptions( native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory, databaseDirectory: getApplicationSupportDirectory,
), ),
// If you need web support, see https://drift.simonbinder.eu/platforms/web/ // 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,
);
});
}
},
);
}
} }

View file

@ -33,9 +33,10 @@ class $DatabaseItemsTable extends DatabaseItems
late final GeneratedColumn<bool> syncing = GeneratedColumn<bool>( late final GeneratedColumn<bool> syncing = GeneratedColumn<bool>(
'syncing', aliasedName, false, 'syncing', aliasedName, false,
type: DriftSqlType.bool, type: DriftSqlType.bool,
requiredDuringInsert: true, requiredDuringInsert: false,
defaultConstraints: defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("syncing" IN (0, 1))')); GeneratedColumn.constraintIsAlways('CHECK ("syncing" IN (0, 1))'),
defaultValue: const Constant(false));
static const VerificationMeta _sortNameMeta = static const VerificationMeta _sortNameMeta =
const VerificationMeta('sortName'); const VerificationMeta('sortName');
@override @override
@ -94,6 +95,16 @@ class $DatabaseItemsTable extends DatabaseItems
late final GeneratedColumn<String> subtitles = GeneratedColumn<String>( late final GeneratedColumn<String> subtitles = GeneratedColumn<String>(
'subtitles', aliasedName, true, 'subtitles', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false); type: DriftSqlType.string, requiredDuringInsert: false);
static const VerificationMeta _unSyncedDataMeta =
const VerificationMeta('unSyncedData');
@override
late final GeneratedColumn<bool> unSyncedData = GeneratedColumn<bool>(
'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 = static const VerificationMeta _userDataMeta =
const VerificationMeta('userData'); const VerificationMeta('userData');
@override @override
@ -115,6 +126,7 @@ class $DatabaseItemsTable extends DatabaseItems
images, images,
chapters, chapters,
subtitles, subtitles,
unSyncedData,
userData userData
]; ];
@override @override
@ -141,8 +153,6 @@ class $DatabaseItemsTable extends DatabaseItems
if (data.containsKey('syncing')) { if (data.containsKey('syncing')) {
context.handle(_syncingMeta, context.handle(_syncingMeta,
syncing.isAcceptableOrUnknown(data['syncing']!, _syncingMeta)); syncing.isAcceptableOrUnknown(data['syncing']!, _syncingMeta));
} else if (isInserting) {
context.missing(_syncingMeta);
} }
if (data.containsKey('sort_name')) { if (data.containsKey('sort_name')) {
context.handle(_sortNameMeta, context.handle(_sortNameMeta,
@ -190,6 +200,12 @@ class $DatabaseItemsTable extends DatabaseItems
context.handle(_subtitlesMeta, context.handle(_subtitlesMeta,
subtitles.isAcceptableOrUnknown(data['subtitles']!, _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')) { if (data.containsKey('user_data')) {
context.handle(_userDataMeta, context.handle(_userDataMeta,
userData.isAcceptableOrUnknown(data['user_data']!, _userDataMeta)); userData.isAcceptableOrUnknown(data['user_data']!, _userDataMeta));
@ -229,6 +245,8 @@ class $DatabaseItemsTable extends DatabaseItems
.read(DriftSqlType.string, data['${effectivePrefix}chapters']), .read(DriftSqlType.string, data['${effectivePrefix}chapters']),
subtitles: attachedDatabase.typeMapping subtitles: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}subtitles']), .read(DriftSqlType.string, data['${effectivePrefix}subtitles']),
unSyncedData: attachedDatabase.typeMapping
.read(DriftSqlType.bool, data['${effectivePrefix}un_synced_data'])!,
userData: attachedDatabase.typeMapping userData: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}user_data']), .read(DriftSqlType.string, data['${effectivePrefix}user_data']),
); );
@ -254,6 +272,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
final String? images; final String? images;
final String? chapters; final String? chapters;
final String? subtitles; final String? subtitles;
final bool unSyncedData;
final String? userData; final String? userData;
const DatabaseItem( const DatabaseItem(
{required this.userId, {required this.userId,
@ -269,6 +288,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
this.images, this.images,
this.chapters, this.chapters,
this.subtitles, this.subtitles,
required this.unSyncedData,
this.userData}); this.userData});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
@ -306,6 +326,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
if (!nullToAbsent || subtitles != null) { if (!nullToAbsent || subtitles != null) {
map['subtitles'] = Variable<String>(subtitles); map['subtitles'] = Variable<String>(subtitles);
} }
map['un_synced_data'] = Variable<bool>(unSyncedData);
if (!nullToAbsent || userData != null) { if (!nullToAbsent || userData != null) {
map['user_data'] = Variable<String>(userData); map['user_data'] = Variable<String>(userData);
} }
@ -344,6 +365,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
subtitles: subtitles == null && nullToAbsent subtitles: subtitles == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(subtitles), : Value(subtitles),
unSyncedData: Value(unSyncedData),
userData: userData == null && nullToAbsent userData: userData == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(userData), : Value(userData),
@ -367,6 +389,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
images: serializer.fromJson<String?>(json['images']), images: serializer.fromJson<String?>(json['images']),
chapters: serializer.fromJson<String?>(json['chapters']), chapters: serializer.fromJson<String?>(json['chapters']),
subtitles: serializer.fromJson<String?>(json['subtitles']), subtitles: serializer.fromJson<String?>(json['subtitles']),
unSyncedData: serializer.fromJson<bool>(json['unSyncedData']),
userData: serializer.fromJson<String?>(json['userData']), userData: serializer.fromJson<String?>(json['userData']),
); );
} }
@ -387,6 +410,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
'images': serializer.toJson<String?>(images), 'images': serializer.toJson<String?>(images),
'chapters': serializer.toJson<String?>(chapters), 'chapters': serializer.toJson<String?>(chapters),
'subtitles': serializer.toJson<String?>(subtitles), 'subtitles': serializer.toJson<String?>(subtitles),
'unSyncedData': serializer.toJson<bool>(unSyncedData),
'userData': serializer.toJson<String?>(userData), 'userData': serializer.toJson<String?>(userData),
}; };
} }
@ -405,6 +429,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
Value<String?> images = const Value.absent(), Value<String?> images = const Value.absent(),
Value<String?> chapters = const Value.absent(), Value<String?> chapters = const Value.absent(),
Value<String?> subtitles = const Value.absent(), Value<String?> subtitles = const Value.absent(),
bool? unSyncedData,
Value<String?> userData = const Value.absent()}) => Value<String?> userData = const Value.absent()}) =>
DatabaseItem( DatabaseItem(
userId: userId ?? this.userId, userId: userId ?? this.userId,
@ -423,6 +448,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
images: images.present ? images.value : this.images, images: images.present ? images.value : this.images,
chapters: chapters.present ? chapters.value : this.chapters, chapters: chapters.present ? chapters.value : this.chapters,
subtitles: subtitles.present ? subtitles.value : this.subtitles, subtitles: subtitles.present ? subtitles.value : this.subtitles,
unSyncedData: unSyncedData ?? this.unSyncedData,
userData: userData.present ? userData.value : this.userData, userData: userData.present ? userData.value : this.userData,
); );
DatabaseItem copyWithCompanion(DatabaseItemsCompanion data) { DatabaseItem copyWithCompanion(DatabaseItemsCompanion data) {
@ -446,6 +472,9 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
images: data.images.present ? data.images.value : this.images, images: data.images.present ? data.images.value : this.images,
chapters: data.chapters.present ? data.chapters.value : this.chapters, chapters: data.chapters.present ? data.chapters.value : this.chapters,
subtitles: data.subtitles.present ? data.subtitles.value : this.subtitles, 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, userData: data.userData.present ? data.userData.value : this.userData,
); );
} }
@ -466,6 +495,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
..write('images: $images, ') ..write('images: $images, ')
..write('chapters: $chapters, ') ..write('chapters: $chapters, ')
..write('subtitles: $subtitles, ') ..write('subtitles: $subtitles, ')
..write('unSyncedData: $unSyncedData, ')
..write('userData: $userData') ..write('userData: $userData')
..write(')')) ..write(')'))
.toString(); .toString();
@ -486,6 +516,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
images, images,
chapters, chapters,
subtitles, subtitles,
unSyncedData,
userData); userData);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@ -504,6 +535,7 @@ class DatabaseItem extends DataClass implements Insertable<DatabaseItem> {
other.images == this.images && other.images == this.images &&
other.chapters == this.chapters && other.chapters == this.chapters &&
other.subtitles == this.subtitles && other.subtitles == this.subtitles &&
other.unSyncedData == this.unSyncedData &&
other.userData == this.userData); other.userData == this.userData);
} }
@ -521,6 +553,7 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
final Value<String?> images; final Value<String?> images;
final Value<String?> chapters; final Value<String?> chapters;
final Value<String?> subtitles; final Value<String?> subtitles;
final Value<bool> unSyncedData;
final Value<String?> userData; final Value<String?> userData;
final Value<int> rowid; final Value<int> rowid;
const DatabaseItemsCompanion({ const DatabaseItemsCompanion({
@ -537,13 +570,14 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
this.images = const Value.absent(), this.images = const Value.absent(),
this.chapters = const Value.absent(), this.chapters = const Value.absent(),
this.subtitles = const Value.absent(), this.subtitles = const Value.absent(),
this.unSyncedData = const Value.absent(),
this.userData = const Value.absent(), this.userData = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}); });
DatabaseItemsCompanion.insert({ DatabaseItemsCompanion.insert({
required String userId, required String userId,
required String id, required String id,
required bool syncing, this.syncing = const Value.absent(),
this.sortName = const Value.absent(), this.sortName = const Value.absent(),
this.parentId = const Value.absent(), this.parentId = const Value.absent(),
this.path = const Value.absent(), this.path = const Value.absent(),
@ -554,11 +588,11 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
this.images = const Value.absent(), this.images = const Value.absent(),
this.chapters = const Value.absent(), this.chapters = const Value.absent(),
this.subtitles = const Value.absent(), this.subtitles = const Value.absent(),
this.unSyncedData = const Value.absent(),
this.userData = const Value.absent(), this.userData = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}) : userId = Value(userId), }) : userId = Value(userId),
id = Value(id), id = Value(id);
syncing = Value(syncing);
static Insertable<DatabaseItem> custom({ static Insertable<DatabaseItem> custom({
Expression<String>? userId, Expression<String>? userId,
Expression<String>? id, Expression<String>? id,
@ -573,6 +607,7 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
Expression<String>? images, Expression<String>? images,
Expression<String>? chapters, Expression<String>? chapters,
Expression<String>? subtitles, Expression<String>? subtitles,
Expression<bool>? unSyncedData,
Expression<String>? userData, Expression<String>? userData,
Expression<int>? rowid, Expression<int>? rowid,
}) { }) {
@ -590,6 +625,7 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
if (images != null) 'images': images, if (images != null) 'images': images,
if (chapters != null) 'chapters': chapters, if (chapters != null) 'chapters': chapters,
if (subtitles != null) 'subtitles': subtitles, if (subtitles != null) 'subtitles': subtitles,
if (unSyncedData != null) 'un_synced_data': unSyncedData,
if (userData != null) 'user_data': userData, if (userData != null) 'user_data': userData,
if (rowid != null) 'rowid': rowid, if (rowid != null) 'rowid': rowid,
}); });
@ -609,6 +645,7 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
Value<String?>? images, Value<String?>? images,
Value<String?>? chapters, Value<String?>? chapters,
Value<String?>? subtitles, Value<String?>? subtitles,
Value<bool>? unSyncedData,
Value<String?>? userData, Value<String?>? userData,
Value<int>? rowid}) { Value<int>? rowid}) {
return DatabaseItemsCompanion( return DatabaseItemsCompanion(
@ -625,6 +662,7 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
images: images ?? this.images, images: images ?? this.images,
chapters: chapters ?? this.chapters, chapters: chapters ?? this.chapters,
subtitles: subtitles ?? this.subtitles, subtitles: subtitles ?? this.subtitles,
unSyncedData: unSyncedData ?? this.unSyncedData,
userData: userData ?? this.userData, userData: userData ?? this.userData,
rowid: rowid ?? this.rowid, rowid: rowid ?? this.rowid,
); );
@ -672,6 +710,9 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
if (subtitles.present) { if (subtitles.present) {
map['subtitles'] = Variable<String>(subtitles.value); map['subtitles'] = Variable<String>(subtitles.value);
} }
if (unSyncedData.present) {
map['un_synced_data'] = Variable<bool>(unSyncedData.value);
}
if (userData.present) { if (userData.present) {
map['user_data'] = Variable<String>(userData.value); map['user_data'] = Variable<String>(userData.value);
} }
@ -697,6 +738,7 @@ class DatabaseItemsCompanion extends UpdateCompanion<DatabaseItem> {
..write('images: $images, ') ..write('images: $images, ')
..write('chapters: $chapters, ') ..write('chapters: $chapters, ')
..write('subtitles: $subtitles, ') ..write('subtitles: $subtitles, ')
..write('unSyncedData: $unSyncedData, ')
..write('userData: $userData, ') ..write('userData: $userData, ')
..write('rowid: $rowid') ..write('rowid: $rowid')
..write(')')) ..write(')'))
@ -722,7 +764,7 @@ typedef $$DatabaseItemsTableCreateCompanionBuilder = DatabaseItemsCompanion
Function({ Function({
required String userId, required String userId,
required String id, required String id,
required bool syncing, Value<bool> syncing,
Value<String?> sortName, Value<String?> sortName,
Value<String?> parentId, Value<String?> parentId,
Value<String?> path, Value<String?> path,
@ -733,6 +775,7 @@ typedef $$DatabaseItemsTableCreateCompanionBuilder = DatabaseItemsCompanion
Value<String?> images, Value<String?> images,
Value<String?> chapters, Value<String?> chapters,
Value<String?> subtitles, Value<String?> subtitles,
Value<bool> unSyncedData,
Value<String?> userData, Value<String?> userData,
Value<int> rowid, Value<int> rowid,
}); });
@ -751,6 +794,7 @@ typedef $$DatabaseItemsTableUpdateCompanionBuilder = DatabaseItemsCompanion
Value<String?> images, Value<String?> images,
Value<String?> chapters, Value<String?> chapters,
Value<String?> subtitles, Value<String?> subtitles,
Value<bool> unSyncedData,
Value<String?> userData, Value<String?> userData,
Value<int> rowid, Value<int> rowid,
}); });
@ -804,6 +848,9 @@ class $$DatabaseItemsTableFilterComposer
ColumnFilters<String> get subtitles => $composableBuilder( ColumnFilters<String> get subtitles => $composableBuilder(
column: $table.subtitles, builder: (column) => ColumnFilters(column)); column: $table.subtitles, builder: (column) => ColumnFilters(column));
ColumnFilters<bool> get unSyncedData => $composableBuilder(
column: $table.unSyncedData, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get userData => $composableBuilder( ColumnFilters<String> get userData => $composableBuilder(
column: $table.userData, builder: (column) => ColumnFilters(column)); column: $table.userData, builder: (column) => ColumnFilters(column));
} }
@ -859,6 +906,10 @@ class $$DatabaseItemsTableOrderingComposer
ColumnOrderings<String> get subtitles => $composableBuilder( ColumnOrderings<String> get subtitles => $composableBuilder(
column: $table.subtitles, builder: (column) => ColumnOrderings(column)); column: $table.subtitles, builder: (column) => ColumnOrderings(column));
ColumnOrderings<bool> get unSyncedData => $composableBuilder(
column: $table.unSyncedData,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get userData => $composableBuilder( ColumnOrderings<String> get userData => $composableBuilder(
column: $table.userData, builder: (column) => ColumnOrderings(column)); column: $table.userData, builder: (column) => ColumnOrderings(column));
} }
@ -911,6 +962,9 @@ class $$DatabaseItemsTableAnnotationComposer
GeneratedColumn<String> get subtitles => GeneratedColumn<String> get subtitles =>
$composableBuilder(column: $table.subtitles, builder: (column) => column); $composableBuilder(column: $table.subtitles, builder: (column) => column);
GeneratedColumn<bool> get unSyncedData => $composableBuilder(
column: $table.unSyncedData, builder: (column) => column);
GeneratedColumn<String> get userData => GeneratedColumn<String> get userData =>
$composableBuilder(column: $table.userData, builder: (column) => column); $composableBuilder(column: $table.userData, builder: (column) => column);
} }
@ -954,6 +1008,7 @@ class $$DatabaseItemsTableTableManager extends RootTableManager<
Value<String?> images = const Value.absent(), Value<String?> images = const Value.absent(),
Value<String?> chapters = const Value.absent(), Value<String?> chapters = const Value.absent(),
Value<String?> subtitles = const Value.absent(), Value<String?> subtitles = const Value.absent(),
Value<bool> unSyncedData = const Value.absent(),
Value<String?> userData = const Value.absent(), Value<String?> userData = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => }) =>
@ -971,13 +1026,14 @@ class $$DatabaseItemsTableTableManager extends RootTableManager<
images: images, images: images,
chapters: chapters, chapters: chapters,
subtitles: subtitles, subtitles: subtitles,
unSyncedData: unSyncedData,
userData: userData, userData: userData,
rowid: rowid, rowid: rowid,
), ),
createCompanionCallback: ({ createCompanionCallback: ({
required String userId, required String userId,
required String id, required String id,
required bool syncing, Value<bool> syncing = const Value.absent(),
Value<String?> sortName = const Value.absent(), Value<String?> sortName = const Value.absent(),
Value<String?> parentId = const Value.absent(), Value<String?> parentId = const Value.absent(),
Value<String?> path = const Value.absent(), Value<String?> path = const Value.absent(),
@ -988,6 +1044,7 @@ class $$DatabaseItemsTableTableManager extends RootTableManager<
Value<String?> images = const Value.absent(), Value<String?> images = const Value.absent(),
Value<String?> chapters = const Value.absent(), Value<String?> chapters = const Value.absent(),
Value<String?> subtitles = const Value.absent(), Value<String?> subtitles = const Value.absent(),
Value<bool> unSyncedData = const Value.absent(),
Value<String?> userData = const Value.absent(), Value<String?> userData = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => }) =>
@ -1005,6 +1062,7 @@ class $$DatabaseItemsTableTableManager extends RootTableManager<
images: images, images: images,
chapters: chapters, chapters: chapters,
subtitles: subtitles, subtitles: subtitles,
unSyncedData: unSyncedData,
userData: userData, userData: userData,
rowid: rowid, rowid: rowid,
), ),

View file

@ -18,7 +18,6 @@ import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/syncing/i_synced_item.dart'; import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
part 'sync_item.freezed.dart'; part 'sync_item.freezed.dart';
@ -42,6 +41,7 @@ class SyncedItem with _$SyncedItem {
ImagesData? fImages, ImagesData? fImages,
@Default([]) List<Chapter> fChapters, @Default([]) List<Chapter> fChapters,
@Default([]) List<SubStreamModel> subtitles, @Default([]) List<SubStreamModel> subtitles,
@Default(false) bool unSyncedData,
@UserDataJsonSerializer() UserData? userData, @UserDataJsonSerializer() UserData? userData,
// ignore: invalid_annotation_target // ignore: invalid_annotation_target
@JsonKey(includeFromJson: false, includeToJson: false) ItemBaseModel? itemModel, @JsonKey(includeFromJson: false, includeToJson: false) ItemBaseModel? itemModel,
@ -67,6 +67,13 @@ class SyncedItem with _$SyncedItem {
[]); []);
File get dataFile => File(joinAll(["$path", "data.json"])); 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])); Directory get trickPlayDirectory => Directory(joinAll(["$path", trickPlayPath]));
File get videoFile => File(joinAll(["$path", "$videoFileName"])); File get videoFile => File(joinAll(["$path", "$videoFileName"]));
Directory get directory => Directory(path ?? ""); Directory get directory => Directory(path ?? "");
@ -104,10 +111,6 @@ class SyncedItem with _$SyncedItem {
return true; return true;
} }
Future<List<SyncedItem>> getChildren(Ref ref) async => await ref.read(syncProvider.notifier).getChildren(this);
Future<List<SyncedItem>> getNestedChildren(Ref ref) async =>
await ref.read(syncProvider.notifier).getNestedChildren(this);
Future<int> get getDirSize async { Future<int> get getDirSize async {
var files = await directory.list(recursive: true).toList(); var files = await directory.list(recursive: true).toList();
var dirSize = files.fold(0, (int sum, file) => sum + file.statSync().size); var dirSize = files.fold(0, (int sum, file) => sum + file.statSync().size);

View file

@ -30,6 +30,7 @@ mixin _$SyncedItem {
ImagesData? get fImages => throw _privateConstructorUsedError; ImagesData? get fImages => throw _privateConstructorUsedError;
List<Chapter> get fChapters => throw _privateConstructorUsedError; List<Chapter> get fChapters => throw _privateConstructorUsedError;
List<SubStreamModel> get subtitles => throw _privateConstructorUsedError; List<SubStreamModel> get subtitles => throw _privateConstructorUsedError;
bool get unSyncedData => throw _privateConstructorUsedError;
@UserDataJsonSerializer() @UserDataJsonSerializer()
UserData? get userData => UserData? get userData =>
throw _privateConstructorUsedError; // ignore: invalid_annotation_target throw _privateConstructorUsedError; // ignore: invalid_annotation_target
@ -64,6 +65,7 @@ abstract class $SyncedItemCopyWith<$Res> {
ImagesData? fImages, ImagesData? fImages,
List<Chapter> fChapters, List<Chapter> fChapters,
List<SubStreamModel> subtitles, List<SubStreamModel> subtitles,
bool unSyncedData,
@UserDataJsonSerializer() UserData? userData, @UserDataJsonSerializer() UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? itemModel}); ItemBaseModel? itemModel});
@ -100,6 +102,7 @@ class _$SyncedItemCopyWithImpl<$Res, $Val extends SyncedItem>
Object? fImages = freezed, Object? fImages = freezed,
Object? fChapters = null, Object? fChapters = null,
Object? subtitles = null, Object? subtitles = null,
Object? unSyncedData = null,
Object? userData = freezed, Object? userData = freezed,
Object? itemModel = freezed, Object? itemModel = freezed,
}) { }) {
@ -160,6 +163,10 @@ class _$SyncedItemCopyWithImpl<$Res, $Val extends SyncedItem>
? _value.subtitles ? _value.subtitles
: subtitles // ignore: cast_nullable_to_non_nullable : subtitles // ignore: cast_nullable_to_non_nullable
as List<SubStreamModel>, as List<SubStreamModel>,
unSyncedData: null == unSyncedData
? _value.unSyncedData
: unSyncedData // ignore: cast_nullable_to_non_nullable
as bool,
userData: freezed == userData userData: freezed == userData
? _value.userData ? _value.userData
: userData // ignore: cast_nullable_to_non_nullable : userData // ignore: cast_nullable_to_non_nullable
@ -209,6 +216,7 @@ abstract class _$$SyncItemImplCopyWith<$Res>
ImagesData? fImages, ImagesData? fImages,
List<Chapter> fChapters, List<Chapter> fChapters,
List<SubStreamModel> subtitles, List<SubStreamModel> subtitles,
bool unSyncedData,
@UserDataJsonSerializer() UserData? userData, @UserDataJsonSerializer() UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? itemModel}); ItemBaseModel? itemModel});
@ -244,6 +252,7 @@ class __$$SyncItemImplCopyWithImpl<$Res>
Object? fImages = freezed, Object? fImages = freezed,
Object? fChapters = null, Object? fChapters = null,
Object? subtitles = null, Object? subtitles = null,
Object? unSyncedData = null,
Object? userData = freezed, Object? userData = freezed,
Object? itemModel = freezed, Object? itemModel = freezed,
}) { }) {
@ -304,6 +313,10 @@ class __$$SyncItemImplCopyWithImpl<$Res>
? _value._subtitles ? _value._subtitles
: subtitles // ignore: cast_nullable_to_non_nullable : subtitles // ignore: cast_nullable_to_non_nullable
as List<SubStreamModel>, as List<SubStreamModel>,
unSyncedData: null == unSyncedData
? _value.unSyncedData
: unSyncedData // ignore: cast_nullable_to_non_nullable
as bool,
userData: freezed == userData userData: freezed == userData
? _value.userData ? _value.userData
: userData // ignore: cast_nullable_to_non_nullable : userData // ignore: cast_nullable_to_non_nullable
@ -334,6 +347,7 @@ class _$SyncItemImpl extends _SyncItem {
this.fImages, this.fImages,
final List<Chapter> fChapters = const [], final List<Chapter> fChapters = const [],
final List<SubStreamModel> subtitles = const [], final List<SubStreamModel> subtitles = const [],
this.unSyncedData = false,
@UserDataJsonSerializer() this.userData, @UserDataJsonSerializer() this.userData,
@JsonKey(includeFromJson: false, includeToJson: false) this.itemModel}) @JsonKey(includeFromJson: false, includeToJson: false) this.itemModel})
: _fChapters = fChapters, : _fChapters = fChapters,
@ -384,6 +398,9 @@ class _$SyncItemImpl extends _SyncItem {
return EqualUnmodifiableListView(_subtitles); return EqualUnmodifiableListView(_subtitles);
} }
@override
@JsonKey()
final bool unSyncedData;
@override @override
@UserDataJsonSerializer() @UserDataJsonSerializer()
final UserData? userData; final UserData? userData;
@ -394,7 +411,7 @@ class _$SyncItemImpl extends _SyncItem {
@override @override
String toString() { 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 /// Create a copy of SyncedItem
@ -422,6 +439,7 @@ abstract class _SyncItem extends SyncedItem {
final ImagesData? fImages, final ImagesData? fImages,
final List<Chapter> fChapters, final List<Chapter> fChapters,
final List<SubStreamModel> subtitles, final List<SubStreamModel> subtitles,
final bool unSyncedData,
@UserDataJsonSerializer() final UserData? userData, @UserDataJsonSerializer() final UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final ItemBaseModel? itemModel}) = _$SyncItemImpl; final ItemBaseModel? itemModel}) = _$SyncItemImpl;
@ -456,6 +474,8 @@ abstract class _SyncItem extends SyncedItem {
@override @override
List<SubStreamModel> get subtitles; List<SubStreamModel> get subtitles;
@override @override
bool get unSyncedData;
@override
@UserDataJsonSerializer() @UserDataJsonSerializer()
UserData? get userData; // ignore: invalid_annotation_target UserData? get userData; // ignore: invalid_annotation_target
@override @override

View file

@ -1,11 +1,13 @@
import 'dart:developer'; import 'dart:developer';
import 'package:chopper/chopper.dart'; import 'package:chopper/chopper.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/providers/auth_provider.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/service_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
@ -33,21 +35,27 @@ class JellyRequest implements Interceptor {
@override @override
FutureOr<Response<BodyType>> intercept<BodyType>(Chain<BodyType> chain) async { FutureOr<Response<BodyType>> intercept<BodyType>(Chain<BodyType> 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 //Use current logged in user otherwise use the authprovider
var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).tempCredentials; var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).tempCredentials;
var headers = loginModel.header(ref); var headers = loginModel.header(ref);
final Response<BodyType> response = await chain.proceed(
applyHeaders(
chain.request.copyWith(
baseUri: serverUrl,
),
headers),
);
final Response<BodyType> response = await chain.proceed( connectivityNotifier.checkConnectivity();
applyHeaders( return response;
chain.request.copyWith( } catch (e) {
baseUri: serverUrl, connectivityNotifier.onStateChange([ConnectivityResult.none]);
), throw Exception('Failed to make request\n$e');
headers), }
);
return response;
} }
} }
@ -67,10 +75,6 @@ class JellyResponse implements Interceptor {
chopperLogger.severe('404 NOT FOUND'); chopperLogger.severe('404 NOT FOUND');
} }
if (response.statusCode == 401) {
// ref.read(sharedUtilityProvider).removeAccount(ref.read(userProvider));
}
return response; return response;
} }
} }

View file

@ -10,7 +10,6 @@ import 'package:fladder/providers/favourites_provider.dart';
import 'package:fladder/providers/image_provider.dart'; import 'package:fladder/providers/image_provider.dart';
import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/shared_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/user_provider.dart';
import 'package:fladder/providers/views_provider.dart'; import 'package:fladder/providers/views_provider.dart';
@ -92,7 +91,6 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
ref.read(viewsProvider.notifier).clear(); ref.read(viewsProvider.notifier).clear();
ref.read(favouritesProvider.notifier).clear(); ref.read(favouritesProvider.notifier).clear();
ref.read(userProvider.notifier).clear(); ref.read(userProvider.notifier).clear();
ref.read(syncProvider.notifier).setup();
} }
void setServer(String server) { void setServer(String server) {

View file

@ -22,7 +22,8 @@ class ConnectivityStatus extends _$ConnectivityStatus {
@override @override
ConnectionState build() { ConnectionState build() {
Connectivity().onConnectivityChanged.listen(onStateChange); Connectivity().onConnectivityChanged.listen(onStateChange);
return ConnectionState.offline; checkConnectivity();
return ConnectionState.mobile;
} }
void onStateChange(List<ConnectivityResult> connectivityResult) { void onStateChange(List<ConnectivityResult> connectivityResult) {
@ -36,4 +37,9 @@ class ConnectivityStatus extends _$ConnectivityStatus {
state = ConnectionState.offline; state = ConnectionState.offline;
} }
} }
void checkConnectivity() async {
final connectivityResult = await Connectivity().checkConnectivity();
onStateChange(connectivityResult);
}
} }

View file

@ -7,7 +7,7 @@ part of 'connectivity_provider.dart';
// ************************************************************************** // **************************************************************************
String _$connectivityStatusHash() => String _$connectivityStatusHash() =>
r'2e9b645c78146ed25456e1286df83761588b8e27'; r'7a4ac96d163a479bd34fc6a3efcd556755f8d5e9';
/// See also [ConnectivityStatus]. /// See also [ConnectivityStatus].
@ProviderFor(ConnectivityStatus) @ProviderFor(ConnectivityStatus)

View file

@ -74,9 +74,9 @@ class EpisodeDetailsProvider extends StateNotifier<EpisodeDetailModel> {
Future<void> _tryToCreateOfflineState(ItemBaseModel item) async { Future<void> _tryToCreateOfflineState(ItemBaseModel item) async {
final syncNotifier = ref.read(syncProvider.notifier); 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; if (episodeModel == null) return;
final seriesSyncedItem = await syncNotifier.getSyncedItem(episodeModel.parentBaseModel); final seriesSyncedItem = await syncNotifier.getSyncedItem(episodeModel.parentBaseModel.id);
if (seriesSyncedItem == null) return; if (seriesSyncedItem == null) return;
final seriesModel = seriesSyncedItem.itemModel as SeriesModel?; final seriesModel = seriesSyncedItem.itemModel as SeriesModel?;
if (seriesModel == null) return; if (seriesModel == null) return;

View file

@ -6,7 +6,6 @@ import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/related_provider.dart'; import 'package:fladder/providers/related_provider.dart';
import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
part 'movies_details_provider.g.dart'; part 'movies_details_provider.g.dart';
@ -30,19 +29,10 @@ class MovieDetails extends _$MovieDetails {
state = newState.copyWith(related: related.body); state = newState.copyWith(related: related.body);
return null; return null;
} catch (e) { } catch (e) {
_tryToCreateOfflineState(item);
return null; 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) { void setSubIndex(int index) {
state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(defaultSubStreamIndex: index)); state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(defaultSubStreamIndex: index));
} }

View file

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

View file

@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:chopper/chopper.dart'; import 'package:chopper/chopper.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/api_provider.dart';
import 'package:fladder/providers/related_provider.dart'; import 'package:fladder/providers/related_provider.dart';
import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
final seriesDetailsProvider = final seriesDetailsProvider =
StateNotifierProvider.autoDispose.family<SeriesDetailViewNotifier, SeriesModel?, String>((ref, id) { StateNotifierProvider.autoDispose.family<SeriesDetailViewNotifier, SeriesModel?, String>((ref, id) {
@ -33,6 +34,14 @@ class SeriesDetailViewNotifier extends StateNotifier<SeriesModel?> {
if (response.body == null) return null; if (response.body == null) return null;
newState = response.bodyOrThrow as SeriesModel; 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: [ final episodes = await api.showsSeriesIdEpisodesGet(seriesId: seriesModel.id, fields: [
ItemFields.mediastreams, ItemFields.mediastreams,
ItemFields.mediasources, ItemFields.mediasources,
@ -45,14 +54,9 @@ class SeriesDetailViewNotifier extends StateNotifier<SeriesModel?> {
episodes.body?.items, episodes.body?.items,
ref, ref,
); );
final episodesCanDownload = newEpisodes.any((episode) => episode.canDownload == true); 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( newState = newState.copyWith(
seasons: SeasonModel.seasonsFromDto(seasons.body?.items, ref) seasons: SeasonModel.seasonsFromDto(seasons.body?.items, ref)
.map((element) => element.copyWith( .map((element) => element.copyWith(
@ -71,26 +75,11 @@ class SeriesDetailViewNotifier extends StateNotifier<SeriesModel?> {
state = newState.copyWith(related: related.body); state = newState.copyWith(related: related.body);
return response; return response;
} catch (e) { } catch (e) {
_tryToCreateOfflineState(seriesModel); log("Error fetching series details: $e");
return null; return null;
} }
} }
Future<void> _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<EpisodeModel>().toList(),
seasons: allChildren.whereType<SeasonModel>().toList(),
);
}
return;
}
void updateEpisodeInfo(EpisodeModel episode) { void updateEpisodeInfo(EpisodeModel episode) {
final index = state?.availableEpisodes?.indexOf(episode); final index = state?.availableEpisodes?.indexOf(episode);

View file

@ -1,9 +1,10 @@
import 'package:chopper/chopper.dart'; import 'package:chopper/chopper.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final relatedUtilityProvider = Provider<RelatedNotifier>((ref) { final relatedUtilityProvider = Provider<RelatedNotifier>((ref) {
return RelatedNotifier(ref: ref); return RelatedNotifier(ref: ref);

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:chopper/chopper.dart'; import 'package:chopper/chopper.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:fladder/fake/fake_jellyfin_open_api.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/account_model.dart';
import 'package:fladder/models/credentials_model.dart'; import 'package:fladder/models/credentials_model.dart';
import 'package:fladder/models/item_base_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/media_segments_model.dart';
import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/providers/auth_provider.dart'; import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/providers/image_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/providers/user_provider.dart';
import 'package:fladder/util/jellyfin_extension.dart'; import 'package:fladder/util/jellyfin_extension.dart';
@ -84,21 +88,78 @@ class JellyService {
Future<Response<ItemBaseModel>> usersUserIdItemsItemIdGet({ Future<Response<ItemBaseModel>> usersUserIdItemsItemIdGet({
String? itemId, String? itemId,
}) async { }) async {
final response = await api.itemsItemIdGet( try {
userId: account?.id, final response = await api.itemsItemIdGet(
itemId: itemId, userId: account?.id,
); itemId: itemId,
return response.copyWith(body: ItemBaseModel.fromBaseDto(response.bodyOrThrow, ref)); );
return response.copyWith(body: ItemBaseModel.fromBaseDto(response.bodyOrThrow, ref));
} catch (e) {
final item = (await ref.read(syncProvider.notifier).getSyncedItem(itemId))?.itemModel;
return Response<ItemBaseModel>(
http.Response("", 202),
item,
);
}
} }
Future<Response<BaseItemDto>> usersUserIdItemsItemIdGetBaseItem({ Future<Response<BaseItemDto>> usersUserIdItemsItemIdGetBaseItem({
String? itemId, String? itemId,
}) async { }) 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<BaseItemDto>(
http.Response("", 202),
value?.data,
)
: Response<BaseItemDto>(
http.Response("", 404),
null,
),
);
}
}
Future<Response<UserData>> userItemsItemIdUserDataGet({
String? itemId,
}) async {
final response = await api.userItemsItemIdUserDataGet(
userId: account?.id, userId: account?.id,
itemId: itemId, itemId: itemId,
); );
return response; return response.copyWith(
body: UserData.fromDto(response.bodyOrThrow),
);
}
Future<Response<UserData>?> 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<Response<ServerQueryResult>> itemsGet({ Future<Response<ServerQueryResult>> itemsGet({
@ -491,8 +552,15 @@ class JellyService {
Future<Response> sessionsPlayingStoppedPost({ Future<Response> sessionsPlayingStoppedPost({
required PlaybackStopInfo? body, 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<Response> sessionsPlayingProgressPost({required PlaybackProgressInfo? body}) async => Future<Response> sessionsPlayingProgressPost({required PlaybackProgressInfo? body}) async =>
api.sessionsPlayingProgressPost(body: body); api.sessionsPlayingProgressPost(body: body);
@ -533,25 +601,51 @@ class JellyService {
bool? enableUserData, bool? enableUserData,
ShowsSeriesIdEpisodesGetSortBy? sortBy, ShowsSeriesIdEpisodesGetSortBy? sortBy,
}) async { }) async {
return api.showsSeriesIdEpisodesGet( try {
seriesId: seriesId, var response = await api.showsSeriesIdEpisodesGet(
userId: account?.id, seriesId: seriesId,
fields: [ userId: account?.id,
...?fields, fields: [
ItemFields.parentid, ...?fields,
], ItemFields.parentid,
isMissing: isMissing, ],
limit: limit, isMissing: isMissing,
sortBy: sortBy, limit: limit,
enableUserData: enableUserData, sortBy: sortBy,
startIndex: startIndex, enableUserData: enableUserData,
adjacentTo: adjacentTo, startIndex: startIndex,
startItemId: startItemId, adjacentTo: adjacentTo,
season: season, startItemId: startItemId,
seasonId: seasonId, season: season,
enableImages: enableImages, seasonId: seasonId,
enableImageTypes: enableImageTypes, 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<BaseItemDtoQueryResult>(
http.Response("", 200),
BaseItemDtoQueryResult(
items: episodes.map((e) => e.data).nonNulls.toList(),
totalRecordCount: episodes.length,
startIndex: 0,
),
);
} else {
return Response<BaseItemDtoQueryResult>(
http.Response("", 400),
const BaseItemDtoQueryResult(
items: [],
totalRecordCount: 0,
startIndex: 0,
),
);
}
}
} }
Future<List<ItemBaseModel>> fetchEpisodeFromShow({ Future<List<ItemBaseModel>> fetchEpisodeFromShow({
@ -566,11 +660,22 @@ class JellyService {
String? itemId, String? itemId,
int? limit, int? limit,
}) async { }) async {
return api.itemsItemIdSimilarGet( try {
userId: account?.id, return await api.itemsItemIdSimilarGet(userId: account?.id, itemId: itemId, limit: limit, fields: [
itemId: itemId, ItemFields.parentid,
limit: limit, ItemFields.candelete,
); ItemFields.candownload,
]);
} catch (e) {
return Response<BaseItemDtoQueryResult>(
http.Response("", 400),
const BaseItemDtoQueryResult(
items: [],
totalRecordCount: 0,
startIndex: 0,
),
);
}
} }
Future<Response<BaseItemDtoQueryResult>> usersUserIdItemsGet({ Future<Response<BaseItemDtoQueryResult>> usersUserIdItemsGet({
@ -692,8 +797,9 @@ class JellyService {
bool? enableUserData, bool? enableUserData,
bool? isMissing, bool? isMissing,
List<ItemFields>? fields, List<ItemFields>? fields,
}) => }) async {
api.showsSeriesIdSeasonsGet( try {
final response = await api.showsSeriesIdSeasonsGet(
seriesId: seriesId, seriesId: seriesId,
isMissing: isMissing, isMissing: isMissing,
enableUserData: enableUserData, enableUserData: enableUserData,
@ -702,6 +808,31 @@ class JellyService {
ItemFields.parentid, 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<BaseItemDtoQueryResult>(
http.Response("", 200),
BaseItemDtoQueryResult(
items: seasons.map((e) => e.data).nonNulls.toList(),
totalRecordCount: seasons.length,
startIndex: 0,
),
);
} else {
return Response<BaseItemDtoQueryResult>(
http.Response("", 400),
const BaseItemDtoQueryResult(
items: [],
totalRecordCount: 0,
startIndex: 0,
),
);
}
}
}
Future<Response<QueryFilters>> itemsFilters2Get({ Future<Response<QueryFilters>> itemsFilters2Get({
String? parentId, String? parentId,
@ -817,33 +948,75 @@ class JellyService {
Future<Response<UserItemDataDto>> usersUserIdFavoriteItemsItemIdPost({ Future<Response<UserItemDataDto>> usersUserIdFavoriteItemsItemIdPost({
required String? itemId, required String? itemId,
}) => }) async {
api.userFavoriteItemsItemIdPost( Response<UserItemDataDto>? response;
try {
response = await api.userFavoriteItemsItemIdPost(
itemId: itemId, itemId: itemId,
userId: account?.id, userId: account?.id,
); );
} finally {
await ref
.read(syncProvider.notifier)
.updateFavoriteItem(itemId, isFavorite: true, responseSuccessful: response?.isSuccessful ?? false);
}
return response;
}
Future<Response<UserItemDataDto>> usersUserIdFavoriteItemsItemIdDelete({ Future<Response<UserItemDataDto>> usersUserIdFavoriteItemsItemIdDelete({
required String? itemId, required String? itemId,
}) => }) async {
api.userFavoriteItemsItemIdDelete( Response<UserItemDataDto>? response;
try {
response = await api.userFavoriteItemsItemIdDelete(
itemId: itemId, itemId: itemId,
userId: account?.id, userId: account?.id,
); );
} finally {
await ref
.read(syncProvider.notifier)
.updateFavoriteItem(itemId, isFavorite: false, responseSuccessful: response?.isSuccessful ?? false);
}
return response;
}
Future<Response<UserItemDataDto>> usersUserIdPlayedItemsItemIdPost({ Future<Response<UserItemDataDto>> usersUserIdPlayedItemsItemIdPost({
required String? itemId, required String? itemId,
DateTime? datePlayed, DateTime? datePlayed,
}) => }) async {
api.userPlayedItemsItemIdPost(itemId: itemId, userId: account?.id, datePlayed: datePlayed); Response<UserItemDataDto>? 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<Response<UserItemDataDto>> usersUserIdPlayedItemsItemIdDelete({ Future<Response<UserItemDataDto>> usersUserIdPlayedItemsItemIdDelete({
required String? itemId, required String? itemId,
}) => }) async {
api.userPlayedItemsItemIdDelete( Response<UserItemDataDto>? response;
try {
response = await api.userPlayedItemsItemIdDelete(
itemId: itemId, itemId: itemId,
userId: account?.id, userId: account?.id,
); );
} finally {
await ref.read(syncProvider.notifier).updatePlayedItem(
itemId,
played: false,
responseSuccessful: response?.isSuccessful ?? false,
);
}
return response;
}
Future<Response<MediaSegmentsModel>?> mediaSegmentsGet({ Future<Response<MediaSegmentsModel>?> mediaSegmentsGet({
required String id, required String id,

View file

@ -63,10 +63,6 @@ final photoViewSettingsProvider = StateNotifierProvider<PhotoViewSettingsNotifie
return PhotoViewSettingsNotifier(ref); return PhotoViewSettingsNotifier(ref);
}); });
final testProviderProvider = StateProvider<int>((ref) {
return 0;
});
class PhotoViewSettingsNotifier extends StateNotifier<PhotoViewSettingsModel> { class PhotoViewSettingsNotifier extends StateNotifier<PhotoViewSettingsModel> {
PhotoViewSettingsNotifier(this.ref) : super(PhotoViewSettingsModel()); PhotoViewSettingsNotifier(this.ref) : super(PhotoViewSettingsModel());

View file

@ -16,13 +16,13 @@ Stream<SyncedItem?> syncedItem(Ref ref, ItemBaseModel? item) {
return Stream.value(null); return Stream.value(null);
} }
return ref.watch(syncProvider.notifier).db.getItem(id).watchSingleOrNull(); return ref.watch(syncProvider.notifier).watchItem(id);
} }
@riverpod @riverpod
class SyncedChildren extends _$SyncedChildren { class SyncedChildren extends _$SyncedChildren {
@override @override
FutureOr<List<SyncedItem>> build(SyncedItem item) => ref.read(syncProvider.notifier).getChildren(item); FutureOr<List<SyncedItem>> build(SyncedItem item) => ref.read(syncProvider.notifier).getChildren(item.id);
} }
@riverpod @riverpod

View file

@ -4,7 +4,7 @@ import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; 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:background_downloader/background_downloader.dart';
import 'package:collection/collection.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/models/video_stream_model.dart';
import 'package:fladder/profiles/default_profile.dart'; import 'package:fladder/profiles/default_profile.dart';
import 'package:fladder/providers/api_provider.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/service_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync/background_download_provider.dart'; import 'package:fladder/providers/sync/background_download_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/shared/fladder_snackbar.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/localization_helper.dart';
import 'package:fladder/util/migration/isar_drift_migration.dart'; import 'package:fladder/util/migration/isar_drift_migration.dart';
@ -51,15 +53,49 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
} }
final Ref ref; final Ref ref;
late final db = AppDatabase(ref); late AppDatabase _db = AppDatabase(ref);
final Directory mobileDirectory; final Directory mobileDirectory;
final String subPath = "Synced"; final String subPath = "Synced";
bool updatingSyncStatus = false;
StreamSubscription<List<SyncedItem>>? _subscription;
@override
set state(SyncSettingsModel value) {
super.state = value;
updateSyncStates();
}
void migrateFromIsar() async { void migrateFromIsar() async {
await isarMigration(ref, db, mainDirectory.path); await isarMigration(ref, _db, mainDirectory.path);
_initializeQueryStream(); _initializeQueryStream();
} }
Future<void> 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() { void _init() {
cleanupTemporaryFiles(); cleanupTemporaryFiles();
ref.listen( ref.listen(
@ -67,25 +103,29 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
(previous, next) { (previous, next) {
if (previous?.id != next?.id) { if (previous?.id != next?.id) {
if (next?.id != null) { if (next?.id != null) {
_initializeQueryStream(); _initializeQueryStream(id: next!.id);
} }
} }
}, },
); );
_initializeQueryStream(); ref.listen(connectivityStatusProvider, (_, next) {
if (next != ConnectionState.offline) {
updateSyncStates();
}
});
migrateFromIsar(); migrateFromIsar();
} }
void _initializeQueryStream() async { void _initializeQueryStream({String? id}) async {
final userId = ref.read(userProvider)?.id; final userId = id ?? ref.read(userProvider)?.id;
if (userId == null) return;
_subscription?.cancel(); _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); state = state.copyWith(items: initItems);
@ -123,8 +163,6 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
} }
} }
StreamSubscription<List<SyncedItem>>? _subscription;
late final JellyService api = ref.read(jellyApiProvider); late final JellyService api = ref.read(jellyApiProvider);
String? get _savePath => !kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS) String? get _savePath => !kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)
@ -163,21 +201,25 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
super.dispose(); super.dispose();
} }
Future<void> refresh() async { Future<void> refresh() async => state = state.copyWith(items: (await _db.getParentItems.get()));
state = state.copyWith(items: (await db.getParentItems.get()));
Future<List<SyncedItem>> getNestedChildren(SyncedItem item) async => _db.getNestedChildren(item);
Future<List<SyncedItem>> getChildren(String parentId) async => await _db.getChildren(parentId).get();
Future<List<SyncedItem>> getSiblings(SyncedItem syncedItem) async {
if (syncedItem.parentId == null) return [];
return getChildren(syncedItem.parentId!);
} }
Future<List<SyncedItem>> getNestedChildren(SyncedItem item) async => db.getNestedChildren(item); Future<SyncedItem?> getSyncedItem(String? id) async {
Future<List<SyncedItem>> getChildren(SyncedItem root) async => await db.getChildren(root.id).get();
Future<SyncedItem?> getSyncedItem(ItemBaseModel? item) async {
final id = item?.id;
if (id == null) return null; if (id == null) return null;
return await db.getItem(id).getSingleOrNull(); return await _db.getItem(id).getSingleOrNull();
} }
Future<SyncedItem?> getParentItem(String id) async => await db.getParent(id).getSingleOrNull(); Stream<SyncedItem?> watchItem(String id) => _db.getItem(id).watchSingleOrNull();
Future<SyncedItem?> getParentItem(String id) async => await _db.getParent(id).getSingleOrNull();
Future<SyncedItem> refreshSyncItem(SyncedItem item) async { Future<SyncedItem> refreshSyncItem(SyncedItem item) async {
List<SyncedItem> itemsToSync = await getNestedChildren(item); List<SyncedItem> itemsToSync = await getNestedChildren(item);
@ -196,7 +238,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
final itemModel = ItemBaseModel.fromBaseDto(itemResponse.bodyOrThrow, ref); 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); SyncedItem newSyncedItem = await _syncItemData(syncedParent, itemModel, itemResponse.bodyOrThrow);
@ -217,7 +259,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
} }
} }
await db.insertMultipleEntries(newItems); await _db.insertMultipleEntries(newItems);
return parentItem; return parentItem;
} }
@ -235,7 +277,9 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
ref.read(clientSettingsProvider.notifier).setSyncPath(selectedDirectory); 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) { final newSync = switch (item) {
EpisodeModel episode => await syncSeries(item.parentBaseModel, episode: episode), EpisodeModel episode => await syncSeries(item.parentBaseModel, episode: episode),
SeasonModel season => await syncSeries(item.parentBaseModel, season: season), SeasonModel season => await syncSeries(item.parentBaseModel, season: season),
@ -254,7 +298,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
} }
void viewDatabase(BuildContext context) => 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<bool> removeSync(BuildContext context, SyncedItem? item) async { Future<bool> removeSync(BuildContext context, SyncedItem? item) async {
try { try {
@ -273,7 +317,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
await ref.read(backgroundDownloaderProvider).cancelTaskWithId(item.taskId!); 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++) { for (var i = 0; i < nestedChildren.length; i++) {
final element = nestedChildren[i]; final element = nestedChildren[i];
@ -291,8 +335,7 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
return true; return true;
} catch (e) { } catch (e) {
log('Error deleting synced item'); log('Error deleting synced item ${e.toString()}');
log(e.toString());
state = state.copyWith(items: state.items.map((e) => e.copyWith(markedForDelete: false)).toList()); state = state.copyWith(items: state.items.map((e) => e.copyWith(markedForDelete: false)).toList());
fladderSnackbar(context, title: context.localized.syncRemoveUnableToDeleteItem); fladderSnackbar(context, title: context.localized.syncRemoveUnableToDeleteItem);
return false; return false;
@ -395,7 +438,16 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
return data?.copyWith(path: fileName); return data?.copyWith(path: fileName);
} }
Future<void> updateItem(SyncedItem syncedItem) async => db.insertItem(syncedItem); Future<int> 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<SyncedItem> deleteFullSyncFiles(SyncedItem syncedItem, DownloadTask? task) async { Future<SyncedItem> deleteFullSyncFiles(SyncedItem syncedItem, DownloadTask? task) async {
await syncedItem.deleteDatFiles(ref); await syncedItem.deleteDatFiles(ref);
@ -504,15 +556,81 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
return null; return null;
} }
Future<void> clear() async { Future<void> removeAllSyncedData() async {
await mainDirectory.delete(recursive: true); if (await mainDirectory.exists()) {
await db.clearDatabase(); await mainDirectory.delete(recursive: true);
}
await _db.close();
await _db.clearDatabase();
_db = AppDatabase(ref);
state = state.copyWith(items: []); state = state.copyWith(items: []);
} }
Future<void> setup() async { Future<void> updatePlaybackPosition({String? itemId, required Duration position}) async {
state = state.copyWith(items: []); if (itemId == null) return;
_init();
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<void> 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<SyncedItem> 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<void> 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<SyncedItem> createSyncItem(BaseItemDto response, {SyncedItem? parent}) async { Future<SyncedItem> createSyncItem(BaseItemDto response, {SyncedItem? parent}) async {
final ItemBaseModel item = ItemBaseModel.fromBaseDto(response, ref); final ItemBaseModel item = ItemBaseModel.fromBaseDto(response, ref);
final existingSyncedItem = await getSyncedItem(item); final existingSyncedItem = await getSyncedItem(item.id);
if (existingSyncedItem != null) return existingSyncedItem; if (existingSyncedItem != null) return existingSyncedItem;
SyncedItem syncItem = await _syncItemData(parent, item, response); SyncedItem syncItem = await _syncItemData(parent, item, response);
if (parent == null) { if (parent == null) {
await db.insertItem(syncItem); await _db.insertItem(syncItem);
} }
return syncItem.copyWith( return syncItem.copyWith(
@ -573,7 +691,7 @@ extension SyncNotifierHelpers on SyncNotifier {
if (!syncItem.directory.existsSync()) return null; if (!syncItem.directory.existsSync()) return null;
await db.insertItem(syncItem); await _db.insertItem(syncItem);
await syncFile(syncItem, skipDownload); 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++) { for (var i = 0; i < itemsToDownload.length; i++) {
final item = itemsToDownload[i]; final item = itemsToDownload[i];

View file

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

View file

@ -63,24 +63,19 @@ class ItemInfoScreenState extends ConsumerState<ItemInfoScreen> {
child: Column( child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
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),
child: Row( child: Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
spacing: 6,
children: [ children: [
Text(
widget.item.name,
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(), const Spacer(),
const SizedBox(width: 6),
IconButton( IconButton(
onPressed: () => context.copyToClipboard(info.model.toString()), onPressed: () => context.copyToClipboard(info.model.toString()),
icon: const Icon(Icons.copy_all_rounded)), icon: const Icon(Icons.copy_all_rounded)),
const SizedBox(width: 6),
IconButton( IconButton(
onPressed: () => ref.read(provider.notifier).getItemInformation(widget.item), onPressed: () => ref.read(provider.notifier).getItemInformation(widget.item),
icon: const Icon(IconsaxPlusLinear.refresh), icon: const Icon(IconsaxPlusLinear.refresh),
@ -88,6 +83,7 @@ class ItemInfoScreenState extends ConsumerState<ItemInfoScreen> {
], ],
), ),
), ),
const Opacity(opacity: 0.3, child: Divider()),
], ],
), ),
), ),

View file

@ -96,7 +96,7 @@ List<Widget> buildClientSettingsDownload(BuildContext context, WidgetRef ref, Fu
context.localized.downloadsClearTitle, context.localized.downloadsClearTitle,
context.localized.downloadsClearDesc, context.localized.downloadsClearDesc,
(context) async { (context) async {
await ref.read(syncProvider.notifier).clear(); await ref.read(syncProvider.notifier).removeAllSyncedData();
setState(() {}); setState(() {});
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },

View file

@ -16,11 +16,8 @@ Future<void> showDialogAdaptive(
return showDialog( return showDialog(
context: context, context: context,
useSafeArea: false, useSafeArea: false,
builder: (context) => Padding( builder: (context) => Dialog.fullscreen(
padding: MediaQuery.paddingOf(context), child: builder(context),
child: Dialog.fullscreen(
child: builder(context),
),
), ),
); );
} }

View file

@ -1,12 +1,14 @@
import 'package:flutter/foundation.dart'; 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:fladder/providers/arguments_provider.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/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart';
import 'package:fladder/widgets/shared/offline_banner.dart';
class DefaultTitleBar extends ConsumerStatefulWidget { class DefaultTitleBar extends ConsumerStatefulWidget {
final String? label; final String? label;
@ -36,9 +38,11 @@ class _DefaultTitleBarState extends ConsumerState<DefaultTitleBar> with WindowLi
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (ref.watch(argumentsStateProvider.select((value) => value.htpcMode))) return const SizedBox.shrink(); if (ref.watch(argumentsStateProvider.select((value) => value.htpcMode))) return const SizedBox.shrink();
final brightness = widget.brightness ?? Theme.of(context).brightness; final theme = Theme.of(context);
final iconColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65); final brightness = widget.brightness ?? theme.brightness;
final surfaceColor = Theme.of(context).colorScheme.surface; 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( return MouseRegion(
onEnter: (event) => setState(() => hovering = true), onEnter: (event) => setState(() => hovering = true),
onExit: (event) => setState(() => hovering = false), onExit: (event) => setState(() => hovering = false),
@ -46,147 +50,160 @@ class _DefaultTitleBarState extends ConsumerState<DefaultTitleBar> with WindowLi
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: isOffline
surfaceColor.withValues(alpha: hovering ? 0.7 : 0), ? [
surfaceColor.withValues(alpha: 0), 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, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
)), )),
height: widget.height, height: widget.height,
child: kIsWeb child: kIsWeb
? const SizedBox.shrink() ? const SizedBox.shrink()
: switch (AdaptiveLayout.of(context).platform) { : Stack(
TargetPlatform.android || TargetPlatform.iOS => SizedBox(height: MediaQuery.paddingOf(context).top), fit: StackFit.expand,
TargetPlatform.windows || TargetPlatform.linux => Row( children: [
children: [ switch (AdaptiveLayout.of(context).platform) {
Expanded( TargetPlatform.android || TargetPlatform.iOS => SizedBox(height: MediaQuery.paddingOf(context).top),
child: Container( TargetPlatform.windows || TargetPlatform.linux => 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),
),
]),
child: Row( child: Row(
children: [ children: [
FutureBuilder<List<bool>>(future: Future.microtask(() async { Expanded(
final isMinimized = await windowManager.isMinimized(); child: Container(
return [isMinimized]; color: Colors.black.withValues(alpha: 0),
}), builder: (context, snapshot) { child: DragToMoveArea(
final isMinimized = snapshot.data?.firstOrNull ?? false; child: Row(
return IconButton( crossAxisAlignment: CrossAxisAlignment.stretch,
style: IconButton.styleFrom( mainAxisSize: MainAxisSize.max,
hoverColor: brightness == Brightness.light children: [
? Colors.black.withValues(alpha: 0.1) Container(
: Colors.white.withValues(alpha: 0.2), padding: const EdgeInsets.only(left: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))), child: DefaultTextStyle(
onPressed: () async { style: TextStyle(
fullScreenHelper.closeFullScreen(ref); color: iconColor,
if (isMinimized) { fontSize: 14,
windowManager.restore(); ),
} else { child: Text(widget.label ?? ""),
windowManager.minimize(); ),
} ),
}, ],
icon: Transform.translate(
offset: const Offset(0, -2),
child: Icon(
Icons.minimize_rounded,
color: iconColor,
size: 20,
), ),
), ),
);
}),
FutureBuilder<List<bool>>(
future: Future.microtask(() async {
final isMaximized = await windowManager.isMaximized();
return [isMaximized];
}),
builder: (BuildContext context, AsyncSnapshot<List<bool>> 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(); Container(
}, decoration: BoxDecoration(boxShadow: [
icon: Transform.translate( BoxShadow(
offset: const Offset(0, -2), color: surfaceColor.withValues(alpha: isOffline ? 0 : 0.5),
child: Icon( blurRadius: 32,
Icons.close_rounded, spreadRadius: 10,
color: iconColor, offset: const Offset(8, -6),
size: 23,
), ),
]),
child: Row(
children: [
FutureBuilder<List<bool>>(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<List<bool>>(
future: Future.microtask(() async {
final isMaximized = await windowManager.isMaximized();
return [isMaximized];
}),
builder: (BuildContext context, AsyncSnapshot<List<bool>> 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()
}, ],
),
), ),
); );
} }

View file

@ -10,21 +10,26 @@ void fladderSnackbar(
bool showCloseButton = false, bool showCloseButton = false,
Duration duration = const Duration(seconds: 3), Duration duration = const Duration(seconds: 3),
}) { }) {
if (!context.mounted) return; try {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( if (!context.mounted) return;
content: Text( ScaffoldMessenger.of(context).showSnackBar(SnackBar(
title, content: Text(
style: Theme.of(context) title,
.textTheme style: Theme.of(context)
.titleMedium .textTheme
?.copyWith(fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSecondary), .titleMedium
), ?.copyWith(fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSecondary),
clipBehavior: Clip.none, ),
showCloseIcon: showCloseButton, clipBehavior: Clip.none,
duration: duration, showCloseIcon: showCloseButton,
padding: const EdgeInsets.all(18), duration: duration,
action: action, 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}) { void fladderSnackbarResponse(BuildContext context, Response? response, {String? altTitle}) {

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/episode_model.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/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart'; import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/flat_button.dart';
@ -151,6 +152,7 @@ class EpisodePoster extends ConsumerWidget {
child: const Icon(Icons.local_movies_outlined), child: const Icon(Icons.local_movies_outlined),
); );
bool episodeAvailable = episode.status == EpisodeStatus.available; bool episodeAvailable = episode.status == EpisodeStatus.available;
final syncedDetails = ref.watch(syncedItemProvider(episode));
return AspectRatio( return AspectRatio(
aspectRatio: 1.76, aspectRatio: 1.76,
child: Column( child: Column(
@ -196,18 +198,18 @@ class EpisodePoster extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
ref.watch(syncedItemProvider(episode)).when( switch (syncedDetails) {
error: (error, stackTrace) => const SizedBox.shrink(), AsyncValue<SyncedItem?>(:final value) => Builder(
data: (syncedItem) { builder: (context) {
if (syncedItem == null) { if (value == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return StatusCard( return StatusCard(
child: SyncButton(item: episode, syncedItem: syncedItem), child: SyncButton(item: episode, syncedItem: value),
); );
}, },
loading: () => const SizedBox.shrink(),
), ),
},
if (episode.userData.isFavourite) if (episode.userData.isFavourite)
const StatusCard( const StatusCard(
color: Colors.red, color: Colors.red,

View file

@ -16,36 +16,36 @@ class SyncButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final nested = ref.watch(syncedNestedChildrenProvider(syncedItem)); final nested = ref.watch(syncedNestedChildrenProvider(syncedItem));
return nested.when( return switch (nested) {
loading: () => const SizedBox.shrink(), AsyncValue<List<SyncedItem>>(:final value) => Builder(
error: (err, stack) => const SizedBox.shrink(), builder: (context) {
data: (children) { final download = ref.watch(syncDownloadStatusProvider(syncedItem, value ?? []));
final download = ref.watch(syncDownloadStatusProvider(syncedItem, children)); final status = download?.status ?? TaskStatus.notFound;
final status = download?.status ?? TaskStatus.notFound; final progress = download?.progress ?? 0.0;
final progress = download?.progress ?? 0.0;
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Icon( Icon(
status == TaskStatus.notFound status == TaskStatus.notFound
? (progress > 0 ? IconsaxPlusLinear.arrow_down_1 : IconsaxPlusLinear.more_circle) ? (progress > 0 ? IconsaxPlusLinear.arrow_down_1 : IconsaxPlusLinear.more_circle)
: status.icon, : status.icon,
color: status.color(context), color: status.color(context),
size: status == TaskStatus.running && progress > 0 ? 16 : null, size: status == TaskStatus.running && progress > 0 ? 16 : null,
), ),
SizedBox.fromSize( SizedBox.fromSize(
size: const Size.fromRadius(10), size: const Size.fromRadius(10),
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeCap: StrokeCap.round, strokeCap: StrokeCap.round,
strokeWidth: 1.5, strokeWidth: 1.5,
color: status.color(context), color: status.color(context),
value: status == TaskStatus.running ? progress.clamp(0.0, 1.0) : 0, value: status == TaskStatus.running ? progress.clamp(0.0, 1.0) : 0,
), ),
), ),
], ],
); );
}, },
); ),
};
} }
} }

View file

@ -230,7 +230,8 @@ class _SyncItemDetailsState extends ConsumerState<SyncItemDetails> {
else if (baseItem?.parentBaseModel != null) else if (baseItem?.parentBaseModel != null)
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
final parentItem = await ref.read(syncProvider.notifier).getSyncedItem(baseItem!.parentBaseModel); final parentItem =
await ref.read(syncProvider.notifier).getSyncedItem(baseItem!.parentBaseModel.id);
setState(() { setState(() {
if (parentItem != null) { if (parentItem != null) {
syncedItem = parentItem; syncedItem = parentItem;

View file

@ -56,8 +56,10 @@ class _SyncedScreenState extends ConsumerState<SyncedScreen> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: padding, padding: padding,
child: Column( child: Wrap(
crossAxisAlignment: CrossAxisAlignment.stretch, alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
spacing: 12, spacing: 12,
children: [ children: [
ElevatedButton( ElevatedButton(
@ -65,7 +67,7 @@ class _SyncedScreenState extends ConsumerState<SyncedScreen> {
child: const Text("View Database"), child: const Text("View Database"),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => ref.read(syncProvider.notifier).db.clearDatabase(), onPressed: () => ref.read(syncProvider.notifier).removeAllSyncedData(),
child: const Text("Clear drift database"), child: const Text("Clear drift database"),
), ),
ElevatedButton( ElevatedButton(

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:screen_brightness/screen_brightness.dart'; import 'package:screen_brightness/screen_brightness.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';

View file

@ -34,9 +34,6 @@ class FladderTheme {
static RoundedRectangleBorder get defaultShape => RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)); static RoundedRectangleBorder get defaultShape => RoundedRectangleBorder(borderRadius: BorderRadius.circular(16));
static RoundedRectangleBorder get largeShape => RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)); 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) { static ThemeData theme(ColorScheme? colorScheme, DynamicSchemeVariant dynamicSchemeVariant) {
final ColorScheme? scheme = generateDynamicColourSchemes(colorScheme, dynamicSchemeVariant); final ColorScheme? scheme = generateDynamicColourSchemes(colorScheme, dynamicSchemeVariant);

View file

@ -110,7 +110,7 @@ extension ItemBaseModelExtensions on ItemBaseModel {
syncAble && syncAble &&
(canDownload ?? false); (canDownload ?? false);
final downloadUrl = ref.read(userProvider.notifier).createDownloadUrl(this); 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 [ return [
if (!exclude.contains(ItemActions.play)) if (!exclude.contains(ItemActions.play))
if (playAble) if (playAble)
@ -185,9 +185,12 @@ extension ItemBaseModelExtensions on ItemBaseModel {
ItemActionButton( ItemActionButton(
icon: const Icon(IconsaxPlusLinear.eye), icon: const Icon(IconsaxPlusLinear.eye),
action: () async { action: () async {
final userData = await ref.read(userProvider.notifier).markAsPlayed(true, id); try {
onUserDataChanged?.call(userData?.bodyOrThrow); final userData = await ref.read(userProvider.notifier).markAsPlayed(true, id);
context.refreshData(); onUserDataChanged?.call(userData?.bodyOrThrow);
} finally {
context.refreshData();
}
}, },
label: Text(context.localized.markAsWatched), label: Text(context.localized.markAsWatched),
), ),
@ -196,18 +199,24 @@ extension ItemBaseModelExtensions on ItemBaseModel {
icon: const Icon(IconsaxPlusLinear.eye_slash), icon: const Icon(IconsaxPlusLinear.eye_slash),
label: Text(context.localized.markAsUnwatched), label: Text(context.localized.markAsUnwatched),
action: () async { action: () async {
final userData = await ref.read(userProvider.notifier).markAsPlayed(false, id); try {
onUserDataChanged?.call(userData?.bodyOrThrow); final userData = await ref.read(userProvider.notifier).markAsPlayed(false, id);
context.refreshData(); onUserDataChanged?.call(userData?.bodyOrThrow);
} finally {
context.refreshData();
}
}, },
), ),
if (!exclude.contains(ItemActions.setFavorite)) if (!exclude.contains(ItemActions.setFavorite))
ItemActionButton( ItemActionButton(
icon: Icon(userData.isFavourite ? IconsaxPlusLinear.heart_remove : IconsaxPlusLinear.heart_add), icon: Icon(userData.isFavourite ? IconsaxPlusLinear.heart_remove : IconsaxPlusLinear.heart_add),
action: () async { action: () async {
final newData = await ref.read(userProvider.notifier).setAsFavorite(!userData.isFavourite, id); try {
onUserDataChanged?.call(newData?.bodyOrThrow); final newData = await ref.read(userProvider.notifier).setAsFavorite(!userData.isFavourite, id);
context.refreshData(); onUserDataChanged?.call(newData?.bodyOrThrow);
} finally {
context.refreshData();
}
}, },
label: Text(userData.isFavourite ? context.localized.removeAsFavorite : context.localized.addAsFavorite), label: Text(userData.isFavourite ? context.localized.removeAsFavorite : context.localized.addAsFavorite),
), ),

View file

@ -101,9 +101,11 @@ Future<void> _playVideo(
if (AdaptiveLayout.of(context).isDesktop) { if (AdaptiveLayout.of(context).isDesktop) {
fullScreenHelper.closeFullScreen(ref); fullScreenHelper.closeFullScreen(ref);
} }
if (context.mounted) { if (context.mounted) {
context.refreshData(); await context.refreshData();
} }
onPlayerExit?.call(); onPlayerExit?.call();
} }
} }
@ -138,7 +140,7 @@ extension BookBaseModelExtension on BookModel? {
); );
parentContext?.refreshData(); parentContext?.refreshData();
if (context.mounted) { if (context.mounted) {
context.refreshData(); await context.refreshData();
} }
} }
} }
@ -176,7 +178,7 @@ extension PhotoAlbumExtension on PhotoAlbumModel? {
), ),
); );
if (context.mounted) { if (context.mounted) {
context.refreshData(); await context.refreshData();
} }
return; return;
} }

View file

@ -54,7 +54,7 @@ class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
} }
} }
if (context.mounted) { if (context.mounted) {
context.refreshData(); await context.refreshData();
} }
} }

View file

@ -1,9 +1,10 @@
import 'package:flutter/foundation.dart'; 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/media_playback_model.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/video_player_provider.dart';
import 'package:fladder/providers/views_provider.dart'; import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/routes/auto_router.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/floating_player_bar.dart';
import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.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/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/hide_on_scroll.dart';
import 'package:fladder/widgets/shared/offline_banner.dart';
class NavigationScaffold extends ConsumerStatefulWidget { class NavigationScaffold extends ConsumerStatefulWidget {
final String? currentRouteName; final String? currentRouteName;
@ -55,17 +58,22 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state)); final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
final showPlayerBar = playerState == VideoPlayerState.minimized; final showPlayerBar = playerState == VideoPlayerState.minimized;
final isDesktop = AdaptiveLayout.of(context).isDesktop; final isDesktop = AdaptiveLayout.of(context).isDesktop || kIsWeb;
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final theme = Theme.of(context);
final paddingOf = mediaQuery.padding; final paddingOf = mediaQuery.padding;
final viewPaddingOf = mediaQuery.viewPadding; final viewPaddingOf = mediaQuery.viewPadding;
final bottomPadding = isDesktop || kIsWeb ? 12.0 : paddingOf.bottom; final bottomPadding = isDesktop ? 12.0 : paddingOf.bottom;
final bottomViewPadding = isDesktop || kIsWeb ? 12.0 : viewPaddingOf.bottom; final bottomViewPadding = isDesktop ? 12.0 : viewPaddingOf.bottom;
final isHomeScreen = currentIndex != -1; final isHomeScreen = currentIndex != -1;
final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline));
final offlineMessageHeight = isOffline && !isDesktop ? 12 : 0;
return PopScope( return PopScope(
canPop: currentIndex == 0, canPop: currentIndex == 0,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
@ -80,9 +88,13 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
child: MediaQuery( child: MediaQuery(
data: mediaQuery.copyWith( data: mediaQuery.copyWith(
padding: paddingOf.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( 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 //Builder to correctly apply new padding
child: Builder(builder: (context) { child: Builder(builder: (context) {
@ -104,27 +116,29 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
currentLocation: currentLocation, currentLocation: currentLocation,
) )
: null, : null,
bottomNavigationBar: isHomeScreen && AdaptiveLayout.viewSizeOf(context) == ViewSize.phone bottomNavigationBar: AnimatedVisibility(
? HideOnScroll( visible: (isHomeScreen && AdaptiveLayout.viewSizeOf(context) == ViewSize.phone),
controller: AdaptiveLayout.scrollOf(context), duration: const Duration(milliseconds: 250),
forceHide: !homeRoutes.any((element) => element.name.contains(currentLocation)), child: HideOnScroll(
child: NestedBottomAppBar( controller: AdaptiveLayout.scrollOf(context),
child: SizedBox( forceHide: !homeRoutes.any((element) => element.name.contains(currentLocation)),
height: 65, child: NestedBottomAppBar(
child: Row( child: SizedBox(
mainAxisAlignment: MainAxisAlignment.spaceAround, height: 65,
crossAxisAlignment: CrossAxisAlignment.stretch, child: Row(
children: widget.destinations mainAxisAlignment: MainAxisAlignment.spaceAround,
.map( crossAxisAlignment: CrossAxisAlignment.stretch,
(destination) => destination.toNavigationButton( children: widget.destinations
widget.currentRouteName == destination.route?.routeName, false, false), .map(
) (destination) => destination.toNavigationButton(
.toList(), widget.currentRouteName == destination.route?.routeName, false, false),
), )
), .toList(),
), ),
) ),
: null, ),
),
),
body: widget.nestedChild != null body: widget.nestedChild != null
? NavigationBody( ? NavigationBody(
child: widget.nestedChild!, child: widget.nestedChild!,
@ -147,7 +161,32 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
child: showPlayerBar ? const FloatingPlayerBar() : const SizedBox.shrink(), 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(),
),
),
),
),
], ],
), ),
); );

View file

@ -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,
),
),
);
}
}

View file

@ -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,
),
),
],
),
),
);
}
}

View file

@ -48,11 +48,11 @@ class _SelectableIconButtonState extends ConsumerState<SelectableIconButton> {
setState(() => loading = true); setState(() => loading = true);
try { try {
await widget.onPressed(); await widget.onPressed();
if (context.mounted) await context.refreshData();
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
} finally { } finally {
setState(() => loading = false); setState(() => loading = false);
if (context.mounted) await context.refreshData();
} }
}, },
child: Padding( child: Padding(

View file

@ -244,10 +244,10 @@ class MediaControlsWrapper extends BaseAudioHandler {
final position = _player?.lastState.position; final position = _player?.lastState.position;
final totalDuration = _player?.lastState.duration; 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)); 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)); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(position: Duration.zero));
smtc?.setPlaybackStatus(PlaybackStatus.stopped); smtc?.setPlaybackStatus(PlaybackStatus.stopped);