Init repo

This commit is contained in:
PartyDonut 2024-09-15 14:12:28 +02:00
commit 764b6034e3
566 changed files with 212335 additions and 0 deletions

View file

@ -0,0 +1,73 @@
import 'dart:developer';
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'api_provider.g.dart';
@riverpod
class JellyApi extends _$JellyApi {
@override
JellyService build() {
return JellyService(
ref,
JellyfinOpenApi.create(
interceptors: [
JellyRequest(ref),
JellyResponse(ref),
HttpLoggingInterceptor(level: Level.basic),
],
));
}
}
class JellyRequest implements RequestInterceptor {
JellyRequest(this.ref);
final Ref ref;
@override
FutureOr<Request> onRequest(Request request) async {
if (request.method == HttpMethod.Post) {
chopperLogger.info('Performed a POST request');
}
final serverUrl = Uri.parse(ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server);
//Use current logged in user otherwise use the authprovider
var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).tempCredentials;
var headers = loginModel.header(ref);
return request.copyWith(
baseUri: serverUrl,
headers: request.headers..addAll(headers),
);
}
}
class JellyResponse implements ResponseInterceptor {
JellyResponse(this.ref);
final Ref ref;
@override
FutureOr<Response<dynamic>> onResponse(Response<dynamic> response) {
if (!response.isSuccessful) {
log('x- ${response.base.statusCode} - ${response.base.reasonPhrase} - ${response.error} - ${response.base.request?.method} ${response.base.request?.url.toString()}');
}
if (response.statusCode == 404) {
chopperLogger.severe('404 NOT FOUND');
}
if (response.statusCode == 401) {
// ref.read(sharedUtilityProvider).removeAccount(ref.read(userProvider));
}
return response;
}
}

View file

@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$jellyApiHash() => r'c0cdc4127e7191523b1356e71c54c93f99020c1e';
/// See also [JellyApi].
@ProviderFor(JellyApi)
final jellyApiProvider =
AutoDisposeNotifierProvider<JellyApi, JellyService>.internal(
JellyApi.new,
name: r'jellyApiProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$jellyApiHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$JellyApi = AutoDisposeNotifier<JellyService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,123 @@
import 'dart:developer';
import 'package:chopper/chopper.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/credentials_model.dart';
import 'package:fladder/models/login_screen_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/dashboard_provider.dart';
import 'package:fladder/providers/favourites_provider.dart';
import 'package:fladder/providers/image_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/views_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final authProvider = StateNotifierProvider<AuthNotifier, LoginScreenModel>((ref) {
return AuthNotifier(ref);
});
class AuthNotifier extends StateNotifier<LoginScreenModel> {
AuthNotifier(this.ref)
: super(
LoginScreenModel(
accounts: [],
tempCredentials: CredentialsModel.createNewCredentials(),
loading: false,
),
);
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response<List<AccountModel>>?> getPublicUsers() async {
try {
var response = await api.usersPublicGet(state.tempCredentials);
if (response.isSuccessful && response.body != null) {
var models = response.body ?? [];
return response.copyWith(body: models.toList());
}
return response.copyWith(body: []);
} catch (e) {
return null;
}
}
Future<Response<AccountModel>?> authenticateByName(String userName, String password) async {
state = state.copyWith(loading: true);
clearAllProviders();
var response = await api.usersAuthenticateByNamePost(userName: userName, password: password);
var serverResponse = await api.systemInfoPublicGet();
CredentialsModel credentials = state.tempCredentials;
if (response.isSuccessful && (response.body?.accessToken?.isNotEmpty ?? false)) {
credentials = credentials.copyWith(
token: response.body?.accessToken ?? "",
serverId: response.body?.serverId,
serverName: serverResponse.body?.serverName ?? "",
);
var imageUrl = ref.read(imageUtilityProvider).getUserImageUrl(response.body?.user?.id ?? "");
AccountModel newUser = AccountModel(
name: response.body?.user?.name ?? "",
id: response.body?.user?.id ?? "",
avatar: imageUrl,
credentials: credentials,
lastUsed: DateTime.now(),
);
ref.read(sharedUtilityProvider).addAccount(newUser);
ref.read(userProvider.notifier).userState = newUser;
state = state.copyWith(loading: false);
return Response(response.base, newUser);
}
state = state.copyWith(loading: false);
return Response(response.base, null);
}
Future<Response?> logOutUser() async {
if (ref.read(userProvider) != null) {
final response = await api.sessionsLogoutPost();
if (response.isSuccessful) {
log('Logged out');
}
state = state.copyWith(tempCredentials: CredentialsModel.createNewCredentials());
await ref.read(sharedUtilityProvider).removeAccount(ref.read(userProvider));
return response;
}
clearAllProviders();
return null;
}
Future<void> switchUser() async {
clearAllProviders();
}
void clearAllProviders() {
ref.read(dashboardProvider.notifier).clear();
ref.read(viewsProvider.notifier).clear();
ref.read(favouritesProvider.notifier).clear();
ref.read(userProvider.notifier).clear();
ref.read(syncProvider.notifier).setup();
}
void setServer(String server) {
state = state.copyWith(
tempCredentials: state.tempCredentials.copyWith(server: server),
);
}
List<AccountModel> getSavedAccounts() {
state = state.copyWith(accounts: ref.read(sharedUtilityProvider).getAccounts());
return state.accounts;
}
void reOrderUsers(int oldIndex, int newIndex) {
final accounts = state.accounts;
final original = accounts.elementAt(oldIndex);
accounts.removeAt(oldIndex);
accounts.insert(newIndex, original);
ref.read(sharedUtilityProvider).saveAccounts(accounts);
}
}

View file

@ -0,0 +1,188 @@
import 'dart:developer';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:chopper/chopper.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/book_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:path_provider/path_provider.dart';
class BookViewerModel {
final BookModel? book;
final bool loading;
final List<String> pages;
final int currentPage;
BookViewerModel({
this.book,
this.loading = false,
this.pages = const [],
this.currentPage = 0,
});
int get clampedCurrentPage => currentPage.clamp(0, pages.length);
BookViewerModel copyWith({
ValueGetter<BookModel?>? book,
bool? loading,
List<String>? pages,
int? currentPage,
}) {
return BookViewerModel(
book: book != null ? book.call() : this.book,
loading: loading ?? this.loading,
pages: pages ?? this.pages,
currentPage: currentPage ?? this.currentPage,
);
}
}
final bookViewerProvider = StateNotifierProvider<BookViewerNotifier, BookViewerModel>((ref) {
return BookViewerNotifier(ref);
});
class BookViewerNotifier extends StateNotifier<BookViewerModel> {
BookViewerNotifier(this.ref) : super(BookViewerModel());
final Ref ref;
late Directory savedDirectory;
late final JellyService api = ref.read(jellyApiProvider);
Future<List<String>?> fetchBook(BookModel? book) async {
final oldState = state.copyWith();
state = state.copyWith(loading: true, book: () => book, currentPage: 0);
//Stop and cleanup old state
await _stopPlaybackOldState(oldState);
if (state.book == null) return null;
try {
final response = await api.itemsItemIdDownloadGet(itemId: state.book?.id);
final bookDirectory = state.book?.id;
String tempDir = (await getTemporaryDirectory()).path;
savedDirectory = Directory('$tempDir/$bookDirectory');
await savedDirectory.create();
File bookFile = File('${savedDirectory.path}/archive.book');
await bookFile.writeAsBytes(response.bodyBytes);
final inputStream = InputFileStream(bookFile.path);
final archive = ZipDecoder().decodeBuffer(inputStream);
final List<String> imagesPath = [];
for (var file in archive.files) {
//filter out files with image extension
if (file.isFile && _isImageFile(file.name)) {
final path = '${savedDirectory.path}/Pages/${file.name}';
final outputStream = OutputFileStream('${savedDirectory.path}/Pages/${file.name}');
file.writeContent(outputStream);
imagesPath.add(path);
outputStream.close();
}
}
state = state.copyWith(pages: imagesPath, loading: false);
await inputStream.close();
await bookFile.delete();
return imagesPath;
} catch (e) {
log(e.toString());
state = state.copyWith(loading: false);
}
return null;
}
//Simple file checker
bool _isImageFile(String filePath) {
final imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp'];
final fileExtension = filePath.toLowerCase().split('.').last;
return imageExtensions.contains(fileExtension);
}
Future<Response?> updatePlayback(int page) async {
if (state.book == null) return null;
if (page == state.currentPage) return null;
state = state.copyWith(currentPage: page);
return await api.sessionsPlayingStoppedPost(
body: PlaybackStopInfo(
itemId: state.book?.id,
mediaSourceId: state.book?.id,
positionTicks: state.clampedCurrentPage * 10000,
),
);
}
Future<Response?> _stopPlaybackOldState(BookViewerModel oldState) async {
if (oldState.book == null) return null;
if (oldState.clampedCurrentPage < oldState.pages.length && oldState.pages.isNotEmpty) {
await ref.read(userProvider.notifier).markAsPlayed(false, oldState.book?.id ?? "");
}
final response = await api.sessionsPlayingStoppedPost(
body: PlaybackStopInfo(
itemId: oldState.book?.id,
mediaSourceId: oldState.book?.id,
positionTicks: oldState.clampedCurrentPage * 10000,
));
if (oldState.clampedCurrentPage >= oldState.pages.length && oldState.pages.isNotEmpty) {
await ref.read(userProvider.notifier).markAsPlayed(true, oldState.book?.id ?? "");
}
await _cleanUp();
return response;
}
Future<Response?> stopPlayback() async {
if (state.book == null) return null;
if (state.clampedCurrentPage < state.pages.length && state.pages.isNotEmpty) {
await ref.read(userProvider.notifier).markAsPlayed(false, state.book?.id ?? "");
}
final response = await api.sessionsPlayingStoppedPost(
body: PlaybackStopInfo(
itemId: state.book?.id,
mediaSourceId: state.book?.id,
positionTicks: state.clampedCurrentPage * 10000,
));
if (state.clampedCurrentPage >= state.pages.length && state.pages.isNotEmpty) {
await ref.read(userProvider.notifier).markAsPlayed(true, state.book?.id ?? "");
}
await _cleanUp();
return response;
}
Future<void> _cleanUp() async {
try {
for (var i = 0; i < state.pages.length; i++) {
final file = File(state.pages[i]);
if (file.existsSync()) {
await file.delete();
}
}
final directoryExists = await savedDirectory.exists();
if (directoryExists) {
await savedDirectory.delete(recursive: true);
}
} catch (e) {
log(e.toString());
}
}
void setPage(double value) => state = state.copyWith(currentPage: value.toInt());
void setBook(BookModel book) => state = state.copyWith(
book: () => book,
);
}

View file

@ -0,0 +1,100 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/boxset_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/util/map_bool_helper.dart';
class _CollectionSetModel {
final List<ItemBaseModel> items;
final Map<BoxSetModel, bool?> collections;
_CollectionSetModel({
required this.items,
required this.collections,
});
_CollectionSetModel copyWith({
List<ItemBaseModel>? items,
Map<BoxSetModel, bool?>? collections,
}) {
return _CollectionSetModel(
items: items ?? this.items,
collections: collections ?? this.collections,
);
}
}
final collectionsProvider = StateNotifierProvider.autoDispose<BoxSetNotifier, _CollectionSetModel>((ref) {
return BoxSetNotifier(ref);
});
class BoxSetNotifier extends StateNotifier<_CollectionSetModel> {
BoxSetNotifier(this.ref) : super(_CollectionSetModel(items: [], collections: {}));
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<void> setItems(List<ItemBaseModel> items) async {
state = state.copyWith(items: items);
return _init();
}
Future<void> _init() async {
final collections = await api.usersUserIdItemsGet(
recursive: true,
includeItemTypes: [
BaseItemKind.boxset,
],
);
final boxsets = collections.body?.items?.map((e) => BoxSetModel.fromBaseDto(e, ref)).toList();
if (state.items.length == 1 && (boxsets?.length ?? 0) < 25) {
final List<Future<bool>> itemChecks = boxsets?.map((element) async {
final itemList = await api.usersUserIdItemsGet(
parentId: element.id,
);
final List<String?> items = (itemList.body?.items ?? []).map((e) => e.id).toList();
return items.contains(state.items.firstOrNull?.id);
}).toList() ??
[];
final List<bool> results = await Future.wait(itemChecks);
final Map<BoxSetModel, bool?> boxSetContainsItemMap = Map.fromIterables(boxsets ?? [], results);
state = state.copyWith(collections: boxSetContainsItemMap);
} else {
final Map<BoxSetModel, bool?> boxSetContainsItemMap =
Map.fromIterables(boxsets ?? [], List.generate(boxsets?.length ?? 0, (index) => null));
state = state.copyWith(collections: boxSetContainsItemMap);
}
}
Future<Response> toggleCollection(
{required BoxSetModel boxSet, required bool value, required ItemBaseModel item}) async {
final Response response = value
? await api.collectionsCollectionIdItemsPost(collectionId: boxSet.id, ids: [item.id])
: await api.collectionsCollectionIdItemsDelete(collectionId: boxSet.id, ids: [item.id]);
if (response.isSuccessful) {
state = state.copyWith(collections: state.collections.setKey(boxSet, response.isSuccessful ? value : !value));
}
return response;
}
Future<Response> addToCollection({required BoxSetModel boxSet, required bool add}) async => add
? await api.collectionsCollectionIdItemsPost(collectionId: boxSet.id, ids: state.items.map((e) => e.id).toList())
: await api.collectionsCollectionIdItemsDelete(
collectionId: boxSet.id, ids: state.items.map((e) => e.id).toList());
Future<void> addToNewCollection({required String name}) async {
final result = await api.collectionsPost(name: name, ids: state.items.map((e) => e.id).toList());
if (result.isSuccessful) {
await _init();
}
}
}

View file

@ -0,0 +1,111 @@
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
import 'package:fladder/models/home_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/util/list_extensions.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final dashboardProvider = StateNotifierProvider<DashboardNotifier, HomeModel>((ref) {
return DashboardNotifier(ref);
});
class DashboardNotifier extends StateNotifier<HomeModel> {
DashboardNotifier(this.ref) : super(HomeModel());
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<void> fetchNextUpAndResume() async {
if (state.loading) return;
state = state.copyWith(loading: true);
final viewTypes =
ref.read(viewsProvider.select((value) => value.dashboardViews)).map((e) => e.collectionType).toSet().toList();
if (viewTypes.containsAny([CollectionType.movies, CollectionType.tvshows])) {
final resumeVideoResponse = await api.usersUserIdItemsResumeGet(
limit: 16,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
],
mediaTypes: [MediaType.video],
enableTotalRecordCount: false,
);
state = state.copyWith(
resumeVideo: resumeVideoResponse.body?.items?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList(),
);
}
if (viewTypes.contains(CollectionType.music)) {
final resumeAudioResponse = await api.usersUserIdItemsResumeGet(
limit: 16,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
],
mediaTypes: [MediaType.audio],
enableTotalRecordCount: false,
);
state = state.copyWith(
resumeAudio: resumeAudioResponse.body?.items?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList(),
);
}
if (viewTypes.contains(CollectionType.books)) {
final resumeBookResponse = await api.usersUserIdItemsResumeGet(
limit: 16,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
],
mediaTypes: [MediaType.book],
enableTotalRecordCount: false,
);
state = state.copyWith(
resumeBooks: resumeBookResponse.body?.items?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList(),
);
}
final nextResponse = await api.showsNextUpGet(
limit: 16,
nextUpDateCutoff: DateTime.now()
.subtract(ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff ?? Duration(days: 28)))),
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
],
);
final next = nextResponse.body?.items
?.map(
(e) => ItemBaseModel.fromBaseDto(e, ref),
)
.toList() ??
[];
state = state.copyWith(nextUp: next, loading: false);
}
void clear() {
state = HomeModel();
}
}

View file

@ -0,0 +1,114 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:dart_mappable/dart_mappable.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'discovery_provider.g.dart';
part 'discovery_provider.mapper.dart';
@riverpod
class ServerDiscovery extends _$ServerDiscovery {
final String discoveryMessage = 'Who is JellyfinServer?';
final int discoveryPort = 7359;
final int maxServerCount = 25;
final Duration timeOut = const Duration(seconds: 5);
late final JellyService api = JellyService(ref, JellyfinOpenApi.create());
@override
Stream<List<DiscoveryInfo>> build() async* {
final List<DiscoveryInfo> discoveredServers = [];
final StreamController<List<DiscoveryInfo>> controller = StreamController<List<DiscoveryInfo>>();
// Bind the socket and start listening
final RawDatagramSocket socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
socket.broadcastEnabled = true;
// Send the broadcast message
socket.send(
utf8.encode(discoveryMessage),
InternetAddress('255.255.255.255'), // Broadcast address
discoveryPort,
);
// log('Discovery message sent. Waiting for response...');
// Set a timer to close the socket after the timeout
Timer timer = Timer(timeOut, () {
// log('Timeout reached, closing socket.');
if (discoveredServers.isEmpty) {
controller.add([]);
}
socket.close();
controller.close(); // Close the stream controller when done
});
socket.listen((RawSocketEvent event) {
if (event == RawSocketEvent.read) {
Datagram? dg = socket.receive();
if (dg != null) {
// Decode the response
String response = utf8.decode(dg.data);
Map<String, dynamic> jsonResponse = jsonDecode(response);
final discovery = DiscoveryInfo.fromMap(jsonResponse);
discoveredServers.add(discovery);
controller.add(List<DiscoveryInfo>.from(discoveredServers)); // Emit the updated list
if (discoveredServers.length >= maxServerCount) {
log('Max servers found, closing socket.');
timer.cancel();
socket.close();
controller.close(); // Close the stream controller
}
}
}
});
yield* controller.stream;
// Handle disposal when the provider is no longer needed
ref.onDispose(() {
timer.cancel();
socket.close();
controller.close();
});
}
}
@MappableClass()
class DiscoveryInfo with DiscoveryInfoMappable {
@MappableField(key: 'Id')
final String id;
@MappableField(key: 'Name')
final String name;
@MappableField(key: 'Address')
final String address;
@MappableField(key: "EndpointAddress")
final String? endPointAddress;
const DiscoveryInfo({
required this.id,
required this.name,
required this.address,
required this.endPointAddress,
});
factory DiscoveryInfo.fromMap(Map<String, dynamic> map) => DiscoveryInfoMapper.fromMap(map);
factory DiscoveryInfo.fromJson(String json) => DiscoveryInfoMapper.fromJson(json);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DiscoveryInfo && other.id == id && other.address == address;
}
@override
int get hashCode => id.hashCode ^ address.hashCode;
}

View file

@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discovery_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$serverDiscoveryHash() => r'f299dab33f48950f0bd91afab1f831fd6e351923';
/// See also [ServerDiscovery].
@ProviderFor(ServerDiscovery)
final serverDiscoveryProvider = AutoDisposeStreamNotifierProvider<
ServerDiscovery, List<DiscoveryInfo>>.internal(
ServerDiscovery.new,
name: r'serverDiscoveryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$serverDiscoveryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ServerDiscovery = AutoDisposeStreamNotifier<List<DiscoveryInfo>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,130 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member
// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter
part of 'discovery_provider.dart';
class DiscoveryInfoMapper extends ClassMapperBase<DiscoveryInfo> {
DiscoveryInfoMapper._();
static DiscoveryInfoMapper? _instance;
static DiscoveryInfoMapper ensureInitialized() {
if (_instance == null) {
MapperContainer.globals.use(_instance = DiscoveryInfoMapper._());
}
return _instance!;
}
@override
final String id = 'DiscoveryInfo';
static String _$id(DiscoveryInfo v) => v.id;
static const Field<DiscoveryInfo, String> _f$id =
Field('id', _$id, key: 'Id');
static String _$name(DiscoveryInfo v) => v.name;
static const Field<DiscoveryInfo, String> _f$name =
Field('name', _$name, key: 'Name');
static String _$address(DiscoveryInfo v) => v.address;
static const Field<DiscoveryInfo, String> _f$address =
Field('address', _$address, key: 'Address');
static String? _$endPointAddress(DiscoveryInfo v) => v.endPointAddress;
static const Field<DiscoveryInfo, String> _f$endPointAddress =
Field('endPointAddress', _$endPointAddress, key: 'EndpointAddress');
@override
final MappableFields<DiscoveryInfo> fields = const {
#id: _f$id,
#name: _f$name,
#address: _f$address,
#endPointAddress: _f$endPointAddress,
};
@override
final bool ignoreNull = true;
static DiscoveryInfo _instantiate(DecodingData data) {
return DiscoveryInfo(
id: data.dec(_f$id),
name: data.dec(_f$name),
address: data.dec(_f$address),
endPointAddress: data.dec(_f$endPointAddress));
}
@override
final Function instantiate = _instantiate;
static DiscoveryInfo fromMap(Map<String, dynamic> map) {
return ensureInitialized().decodeMap<DiscoveryInfo>(map);
}
static DiscoveryInfo fromJson(String json) {
return ensureInitialized().decodeJson<DiscoveryInfo>(json);
}
}
mixin DiscoveryInfoMappable {
String toJson() {
return DiscoveryInfoMapper.ensureInitialized()
.encodeJson<DiscoveryInfo>(this as DiscoveryInfo);
}
Map<String, dynamic> toMap() {
return DiscoveryInfoMapper.ensureInitialized()
.encodeMap<DiscoveryInfo>(this as DiscoveryInfo);
}
DiscoveryInfoCopyWith<DiscoveryInfo, DiscoveryInfo, DiscoveryInfo>
get copyWith => _DiscoveryInfoCopyWithImpl(
this as DiscoveryInfo, $identity, $identity);
@override
String toString() {
return DiscoveryInfoMapper.ensureInitialized()
.stringifyValue(this as DiscoveryInfo);
}
}
extension DiscoveryInfoValueCopy<$R, $Out>
on ObjectCopyWith<$R, DiscoveryInfo, $Out> {
DiscoveryInfoCopyWith<$R, DiscoveryInfo, $Out> get $asDiscoveryInfo =>
$base.as((v, t, t2) => _DiscoveryInfoCopyWithImpl(v, t, t2));
}
abstract class DiscoveryInfoCopyWith<$R, $In extends DiscoveryInfo, $Out>
implements ClassCopyWith<$R, $In, $Out> {
$R call({String? id, String? name, String? address, String? endPointAddress});
DiscoveryInfoCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
}
class _DiscoveryInfoCopyWithImpl<$R, $Out>
extends ClassCopyWithBase<$R, DiscoveryInfo, $Out>
implements DiscoveryInfoCopyWith<$R, DiscoveryInfo, $Out> {
_DiscoveryInfoCopyWithImpl(super.value, super.then, super.then2);
@override
late final ClassMapperBase<DiscoveryInfo> $mapper =
DiscoveryInfoMapper.ensureInitialized();
@override
$R call(
{String? id,
String? name,
String? address,
Object? endPointAddress = $none}) =>
$apply(FieldCopyWithData({
if (id != null) #id: id,
if (name != null) #name: name,
if (address != null) #address: address,
if (endPointAddress != $none) #endPointAddress: endPointAddress
}));
@override
DiscoveryInfo $make(CopyWithData data) => DiscoveryInfo(
id: data.get(#id, or: $value.id),
name: data.get(#name, or: $value.name),
address: data.get(#address, or: $value.address),
endPointAddress: data.get(#endPointAddress, or: $value.endPointAddress));
@override
DiscoveryInfoCopyWith<$R2, DiscoveryInfo, $Out2> $chain<$R2, $Out2>(
Then<$Out2, $R2> t) =>
_DiscoveryInfoCopyWithImpl($value, $cast, t);
}

View file

@ -0,0 +1,234 @@
import 'dart:convert';
import 'package:chopper/chopper.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/item_editing_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/providers/api_provider.dart';
final editItemProvider =
StateNotifierProvider.autoDispose<EditItemNotifier, ItemEditingModel>((ref) => EditItemNotifier(ref));
class EditItemNotifier extends StateNotifier<ItemEditingModel> {
EditItemNotifier(this.ref) : super(ItemEditingModel());
final Ref ref;
late final api = ref.read(jellyApiProvider);
Map<String, dynamic>? get getFields => state.editAbleFields();
Map<String, dynamic>? get advancedFields => state.editAdvancedAbleFields(ref);
Future<void> fetchInformation(String id) async {
state = ItemEditingModel();
final itemResponse = await api.usersUserIdItemsItemIdGet(
itemId: id,
);
final itemModel = itemResponse.body;
if (itemModel == null) return;
final images = await api.itemsItemIdImagesGet(itemId: itemModel.id);
state = state.copyWith(
item: () => itemModel,
json: () => jsonDecode(itemResponse.bodyString),
editedJson: () => jsonDecode(itemResponse.bodyString),
primary: state.primary.copyWith(
serverImages: images.body
?.where((element) => element.imageType == ImageType.primary)
.map((e) => EditingImageModel.fromImage(e, itemModel.id, ref))
.toList(),
),
logo: state.logo.copyWith(
serverImages: images.body
?.where((element) => element.imageType == ImageType.logo)
.map((e) => EditingImageModel.fromImage(e, itemModel.id, ref))
.toList(),
),
backdrop: state.backdrop.copyWith(
serverImages: images.body
?.where((element) => element.imageType == ImageType.backdrop)
.map((e) => EditingImageModel.fromImage(e, itemModel.id, ref))
.toList(),
),
);
final response = await api.itemsItemIdMetadataEditorGet(itemId: id);
state = state.copyWith(
editorInfo: () => response.bodyOrThrow,
);
}
Future<Response<dynamic>?> fetchRemoteImages({ImageType type = ImageType.primary}) async {
final currentItem = state.item;
if (currentItem == null) return null;
final response = await api.itemsItemIdRemoteImagesGet(
itemId: currentItem.id,
type: type,
includeAllLanguages: state.includeAllImages,
);
final newImages = (response.body?.images ?? []).map((e) => EditingImageModel.fromDto(e)).toList();
switch (type) {
case ImageType.backdrop:
state = state.copyWith(backdrop: state.backdrop.copyWith(images: newImages));
case ImageType.logo:
state = state.copyWith(logo: state.logo.copyWith(images: newImages));
case ImageType.primary:
default:
state = state.copyWith(primary: state.primary.copyWith(images: newImages));
}
return response;
}
Future<void> updateField(MapEntry<String, dynamic> field) async {
final editedJson = state.editedJson;
editedJson?.update(
field.key,
(value) => field.value,
ifAbsent: () => editedJson.addEntries({field}),
);
state = state.copyWith(
editedJson: () => editedJson,
);
}
Future<void> resetChanged() async {
state = state.copyWith(
editedJson: () => state.json,
);
}
Future<Response<ItemBaseModel>?> saveInformation() async {
final currentItem = state.item;
if (currentItem == null) return null;
final jsonBody = state.editedJson;
if (jsonBody == null) return null;
state = state.copyWith(saving: true);
final response = await api.itemsItemIdPost(
itemId: currentItem.id,
body: BaseItemDto.fromJson(jsonBody),
);
await state.primary.setImage(
ImageType.primary,
uploadData: uploadImage,
uploadUrl: _setImage,
);
await state.logo.setImage(
ImageType.logo,
uploadData: uploadImage,
uploadUrl: _setImage,
);
await state.backdrop.setImage(
ImageType.backdrop,
uploadData: uploadImage,
uploadUrl: _setImage,
);
final newItem = await api.usersUserIdItemsItemIdGet(itemId: currentItem.id);
state = state.copyWith(saving: false);
return response.copyWith(body: newItem.body);
}
Future<Response<dynamic>?> uploadImage(EditingImageModel? imageModel) async {
final currentItem = state.item;
if (currentItem == null || imageModel == null) return null;
final response = await api.itemIdImagesImageTypePost(
imageModel.type,
currentItem.id,
imageModel.imageData!,
);
return response;
}
Future<Response<dynamic>?> _setImage(EditingImageModel? imageModel) async {
final currentItem = state.item;
if (currentItem == null) return null;
if (imageModel == null) return null;
return await api.itemsItemIdRemoteImagesDownloadPost(
itemId: state.item?.id,
type: imageModel.type,
imageUrl: imageModel.url,
);
}
void selectImage(ImageType type, EditingImageModel? image) {
switch (type) {
case ImageType.primary:
state = state.copyWith(
primary: state.primary.copyWith(selected: () => state.primary.selected == image ? null : image));
case ImageType.logo:
state = state.copyWith(logo: state.logo.copyWith(selected: () => state.logo.selected == image ? null : image));
default:
if (image == null) return;
state = state.copyWith(
backdrop: state.backdrop.copyWith(
selection: state.backdrop.selection.contains(image)
? (List.of(state.backdrop.selection)..remove(image))
: [...state.backdrop.selection, image],
),
);
return;
}
}
void setIncludeImages(bool value) => state = state.copyWith(includeAllImages: value);
void addCustomImages(ImageType type, Iterable<EditingImageModel> list) {
switch (type) {
case ImageType.primary:
state = state.copyWith(
primary: state.primary.copyWith(
customImages: [...state.primary.customImages, ...list],
selected: () => list.firstOrNull,
),
);
return;
case ImageType.logo:
state = state.copyWith(
logo: state.logo.copyWith(
customImages: [...state.logo.customImages, ...list],
selected: () => list.firstOrNull,
),
);
return;
case ImageType.backdrop:
state = state.copyWith(
backdrop: state.backdrop.copyWith(
customImages: [...state.backdrop.customImages, ...list],
selection: [...state.backdrop.selection, ...list]),
);
default:
return;
}
}
Future<Response<dynamic>?> deleteImage(ImageType type, EditingImageModel image) async {
final currentItem = state.item;
if (currentItem == null) return null;
final response = await api.itemsItemIdImagesImageTypeDelete(
itemId: state.item?.id,
imageType: type,
imageIndex: image.index,
);
switch (type) {
case ImageType.primary:
state = state.copyWith(
primary: state.primary
.copyWith(serverImages: state.primary.serverImages..removeWhere((element) => element == image)),
);
case ImageType.logo:
state = state.copyWith(
logo: state.logo.copyWith(serverImages: state.logo.serverImages..removeWhere((element) => element == image)),
);
case ImageType.backdrop:
state = state.copyWith(
backdrop: state.backdrop
.copyWith(serverImages: state.backdrop.serverImages..removeWhere((element) => element == image)),
);
default:
}
return response;
}
}

View file

@ -0,0 +1,81 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/favourites_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/view_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final favouritesProvider = StateNotifierProvider<FavouritesNotifier, FavouritesModel>((ref) {
return FavouritesNotifier(ref);
});
class FavouritesNotifier extends StateNotifier<FavouritesModel> {
FavouritesNotifier(this.ref) : super(FavouritesModel());
final Ref ref;
late final api = ref.read(jellyApiProvider);
Future<void> fetchFavourites() async {
if (state.loading) return;
state = state.copyWith(loading: true);
await _fetchMoviesAndSeries();
await _fetchPeople();
state = state.copyWith(loading: false);
}
Future<void> _fetchMoviesAndSeries() async {
final views = ref.read(viewsProvider);
final mappedList = await Future.wait(views.dashboardViews.map((viewModel) => _loadLibrary(viewModel: viewModel)));
state = state.copyWith(
favourites: (mappedList
.expand((innerList) => innerList ?? [])
.where((item) => item != null)
.cast<ItemBaseModel>()
.toList())
.groupedItems);
}
Future<List<ItemBaseModel>?> _loadLibrary({ViewModel? viewModel}) async {
final response = await api.itemsGet(
parentId: viewModel?.id,
isFavorite: true,
limit: 10,
sortOrder: [SortOrder.ascending],
sortBy: [ItemSortBy.seriessortname, ItemSortBy.sortname],
);
final response2 = await api.itemsGet(
parentId: viewModel?.id,
isFavorite: true,
recursive: true,
limit: 10,
includeItemTypes: [BaseItemKind.photo, BaseItemKind.episode, BaseItemKind.video, BaseItemKind.collectionfolder],
sortOrder: [SortOrder.ascending],
sortBy: [ItemSortBy.seriessortname, ItemSortBy.sortname],
);
return [...?response.body?.items, ...?response2.body?.items];
}
Future<Response<List<ItemBaseModel>>?> _fetchPeople() async {
final response = await api.personsGet(
limit: 20,
isFavorite: true,
);
state = state.copyWith(people: response.body ?? []);
return response;
}
void setSearch(String value) {
state = state.copyWith(searchQuery: value);
}
void clear() {
state = FavouritesModel();
}
}

View file

@ -0,0 +1,90 @@
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
const _defaultHeight = 576;
const _defaultWidth = 384;
const _defaultQuality = 96;
final imageUtilityProvider = Provider<ImageNotifier>((ref) {
return ImageNotifier(ref: ref);
});
class ImageNotifier {
final Ref ref;
ImageNotifier({
required this.ref,
});
String get currentServerUrl {
return ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server;
}
String getUserImageUrl(String id) {
return Uri.decodeFull("$currentServerUrl/Users/$id/Images/${ImageType.primary.value}");
}
String getItemsImageUrl(String itemId,
{ImageType type = ImageType.primary,
int maxHeight = _defaultHeight,
int maxWidth = _defaultWidth,
int quality = _defaultQuality}) {
try {
return Uri.decodeFull(
"$currentServerUrl/Items/$itemId/Images/${type.value}?fillHeight=$maxHeight&fillWidth=$maxWidth&quality=$quality");
} catch (e) {
return "";
}
}
String getItemsOrigImageUrl(String itemId, {ImageType type = ImageType.primary}) {
try {
return Uri.decodeFull("$currentServerUrl/Items/$itemId/Images/${type.value}");
} catch (e) {
return "";
}
}
String getBackdropOrigImage(
String itemId,
int index,
String hash,
) {
try {
return Uri.decodeFull("$currentServerUrl/Items/$itemId/Images/Backdrop/$index?tag=$hash");
} catch (e) {
return "";
}
}
String getBackdropImage(
String itemId,
int index,
String hash, {
int maxHeight = _defaultHeight,
int maxWidth = _defaultWidth,
int quality = _defaultQuality,
}) {
try {
return Uri.decodeFull(
"$currentServerUrl/Items/$itemId/Images/Backdrop/$index?tag=$hash&fillHeight=$maxHeight&fillWidth=$maxWidth&quality=$quality");
} catch (e) {
return "";
}
}
String getChapterUrl(String itemId, int index,
{ImageType type = ImageType.primary,
int maxHeight = _defaultHeight,
int maxWidth = _defaultWidth,
int quality = _defaultQuality}) {
try {
return Uri.decodeFull(
"$currentServerUrl/Items/$itemId/Images/Chapter/$index?fillHeight=$maxHeight&fillWidth=$maxWidth&quality=$quality");
} catch (e) {
return "";
}
}
}

View file

@ -0,0 +1,149 @@
import 'dart:io';
import 'package:chopper/chopper.dart';
import 'package:collection/collection.dart';
import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/models/library_search/library_search_options.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/book_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart';
class BookProviderModel {
final List<BookModel> chapters;
final ItemBaseModel? parentModel;
BookProviderModel({
this.chapters = const [],
this.parentModel,
});
BookModel? get book => chapters.firstOrNull;
ImagesData? get cover => parentModel?.getPosters ?? book?.getPosters;
List<BookModel> get allBooks {
if (chapters.isEmpty) return [book].whereNotNull().toList();
return chapters;
}
bool get collectionPlayed {
if (chapters.isEmpty) return book?.userData.played ?? false;
for (var i = 0; i < chapters.length; i++) {
if (!chapters[i].userData.played) {
return false;
}
}
return true;
}
BookModel? get nextUp {
if (chapters.isEmpty) return book;
return chapters.lastWhereOrNull((element) => element.currentPage != 0) ??
chapters.firstWhereOrNull((element) => !element.userData.played) ??
chapters.first;
}
BookModel? nextChapter(BookModel? currentBook) {
if (currentBook != null && chapters.isEmpty) return null;
final currentChapter = chapters.indexOf(currentBook!);
// Check if the current chapter is the last one
if (currentChapter == chapters.length - 1) return null;
// Return the next chapter
return chapters[currentChapter + 1];
}
BookModel? previousChapter(BookModel? currentBook) {
if (currentBook != null && chapters.isEmpty) return null;
final currentChapter = chapters.indexOf(currentBook!);
// Check if the current chapter is the first one
if (currentChapter == 0) return null;
// Return the previous chapter
return chapters[currentChapter - 1];
}
BookProviderModel copyWith({
List<BookModel>? chapters,
ValueGetter<ItemBaseModel?>? parentModel,
}) {
return BookProviderModel(
chapters: chapters ?? this.chapters,
parentModel: parentModel != null ? parentModel.call() : this.parentModel,
);
}
}
final bookDetailsProvider =
StateNotifierProvider.autoDispose.family<BookDetailsProviderNotifier, BookProviderModel, String>((ref, id) {
return BookDetailsProviderNotifier(ref);
});
class BookDetailsProviderNotifier extends StateNotifier<BookProviderModel> {
BookDetailsProviderNotifier(this.ref) : super(BookProviderModel());
final Ref ref;
late Directory savedDirectory;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response?> fetchDetails(BookModel book) async {
state = state.copyWith(
parentModel: () => book,
);
String bookId = state.book?.id ?? book.id;
final response = await api.usersUserIdItemsItemIdGet(itemId: bookId);
final parentResponse = await api.usersUserIdItemsItemIdGet(itemId: response.body?.parentId);
final parentModel = parentResponse.bodyOrThrow;
final getViews = await api.usersUserIdViewsGet();
//Hacky solution more false positives so good enough for now.
final parentIsView =
getViews.body?.items?.firstWhereOrNull((element) => element.name == parentResponse.body?.name) != null;
Response<ServerQueryResult>? siblingsResponse;
if (!parentIsView) {
siblingsResponse = await api.itemsGet(
parentId: parentModel.id,
recursive: true,
sortBy: SortingOptions.name.toSortBy,
fields: [
ItemFields.genres,
ItemFields.parentid,
ItemFields.tags,
ItemFields.datecreated,
ItemFields.datelastmediaadded,
ItemFields.parentid,
ItemFields.overview,
ItemFields.originaltitle,
ItemFields.primaryimageaspectratio,
],
includeItemTypes: [
BaseItemKind.book,
],
);
} else {
siblingsResponse = null;
}
final openedBook = response.bodyOrThrow;
state = state.copyWith(
parentModel: !parentIsView ? () => parentResponse.bodyOrThrow : null,
chapters: (siblingsResponse?.body?.items ?? [openedBook]).whereType<BookModel>().whereNotNull().toList(),
);
return response;
}
}

View file

@ -0,0 +1,106 @@
import 'package:chopper/chopper.dart';
import 'package:collection/collection.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/series_model.dart';
import 'package:fladder/providers/api_provider.dart';
class EpisodeDetailModel {
final SeriesModel? series;
final List<EpisodeModel> episodes;
final EpisodeModel? episode;
EpisodeDetailModel({
this.series,
this.episodes = const [],
this.episode,
});
EpisodeDetailModel copyWith({
SeriesModel? series,
List<EpisodeModel>? episodes,
EpisodeModel? episode,
}) {
return EpisodeDetailModel(
series: series ?? this.series,
episodes: episodes ?? this.episodes,
episode: episode ?? this.episode,
);
}
}
final episodeDetailsProvider =
StateNotifierProvider.autoDispose.family<EpisodeDetailsProvider, EpisodeDetailModel, String>((ref, id) {
return EpisodeDetailsProvider(ref);
});
class EpisodeDetailsProvider extends StateNotifier<EpisodeDetailModel> {
EpisodeDetailsProvider(this.ref) : super(EpisodeDetailModel());
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response?> fetchDetails(ItemBaseModel item) async {
try {
final seriesResponse = await api.usersUserIdItemsItemIdGet(itemId: item.parentBaseModel.id);
if (seriesResponse.body == null) return null;
final episodes = await api.showsSeriesIdEpisodesGet(seriesId: item.parentBaseModel.id);
if (episodes.body == null) return null;
final episode = (await api.usersUserIdItemsItemIdGet(itemId: item.id)).bodyOrThrow as EpisodeModel;
state = state.copyWith(
series: seriesResponse.bodyOrThrow as SeriesModel,
episodes: EpisodeModel.episodesFromDto(episodes.bodyOrThrow.items, ref),
episode: episode,
);
return seriesResponse;
} catch (e) {
_tryToCreateOfflineState(item);
return null;
}
}
void _tryToCreateOfflineState(ItemBaseModel item) {
final syncNotifier = ref.read(syncProvider.notifier);
final syncedItem = syncNotifier.getParentItem(item.id);
if (syncedItem == null) return;
final seriesModel = syncedItem.createItemModel(ref) as SeriesModel;
final episodes = ref
.read(syncProvider.notifier)
.getChildren(syncedItem)
.map(
(e) => e.createItemModel(ref) as EpisodeModel,
)
.whereNotNull()
.toList();
state = state.copyWith(
series: seriesModel,
episode: episodes.firstWhereOrNull((element) => element.id == item.id),
episodes: episodes,
);
return;
}
void setSubIndex(int index) {
state = state.copyWith(
episode: state.episode?.copyWith(
mediaStreams: state.episode?.mediaStreams.copyWith(
defaultSubStreamIndex: index,
)));
}
void setAudioIndex(int index) {
state = state.copyWith(
episode: state.episode?.copyWith(
mediaStreams: state.episode?.mediaStreams.copyWith(
defaultAudioStreamIndex: index,
)));
}
}

View file

@ -0,0 +1,42 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/items/folder_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final folderDetailsProvider =
StateNotifierProvider.autoDispose.family<FolderDetailsNotifier, FolderModel?, String>((ref, id) {
return FolderDetailsNotifier(ref);
});
class FolderDetailsNotifier extends StateNotifier<FolderModel?> {
FolderDetailsNotifier(this.ref) : super(null);
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response?> fetchDetails(String id) async {
if (state == null) {
final folderItem = await api.usersUserIdItemsItemIdGet(itemId: id);
if (folderItem.body != null) {
state = folderItem.bodyOrThrow as FolderModel;
}
}
final response = await api.itemsGet(
parentId: id,
sortBy: [ItemSortBy.sortname, ItemSortBy.name],
sortOrder: [SortOrder.ascending],
fields: [
ItemFields.primaryimageaspectratio,
ItemFields.childcount,
],
);
state = state?.copyWith(items: response.body?.items.where((element) => element.childCount != 0).toList());
return response;
}
}

View file

@ -0,0 +1,132 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/models/items/series_model.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart';
class IdentifyModel {
final ItemBaseModel? item;
final String searchString;
final List<ExternalIdInfo> externalIds;
final Map<String, String> keys;
final List<RemoteSearchResult> results;
final int? year;
final bool replaceAllImages;
final bool processing;
IdentifyModel({
this.item,
this.searchString = "",
this.externalIds = const [],
this.keys = const {},
this.results = const [],
this.year,
this.replaceAllImages = true,
this.processing = false,
});
Map<String, dynamic> get body => {
"SearchInfo": {
"ProviderIds": keys,
"Name": searchString,
"Year": year,
},
"ItemId": item?.id,
}..removeWhere((key, value) => value == null);
IdentifyModel copyWith({
ValueGetter<ItemBaseModel?>? item,
String? searchString,
List<ExternalIdInfo>? externalIds,
Map<String, String>? keys,
List<RemoteSearchResult>? results,
ValueGetter<int?>? year,
bool? replaceAllImages,
bool? processing,
}) {
return IdentifyModel(
item: item != null ? item() : this.item,
searchString: searchString ?? this.searchString,
externalIds: externalIds ?? this.externalIds,
keys: keys ?? this.keys,
results: results ?? this.results,
year: year != null ? year() : this.year,
replaceAllImages: replaceAllImages ?? this.replaceAllImages,
processing: processing ?? this.processing,
);
}
}
final simpleProviderProvider = StateProvider<String>((ref) {
return "";
});
final identifyProvider = StateNotifierProvider.autoDispose.family<IdentifyNotifier, IdentifyModel, String>((ref, id) {
return IdentifyNotifier(ref, id);
});
class IdentifyNotifier extends StateNotifier<IdentifyModel> {
IdentifyNotifier(this.ref, this.id) : super(IdentifyModel());
final String id;
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<void> fetchInformation() async {
state = state.copyWith(processing: true);
final item = await api.usersUserIdItemsItemIdGet(itemId: id);
final itemModel = item.bodyOrThrow;
final response = await api.itemsItemIdExternalIdInfosGet(itemId: id);
state = state.copyWith(
item: () => itemModel,
externalIds: response.body,
searchString: itemModel.name,
year: () => itemModel.overview.yearAired,
keys: {for (var element in response.body ?? []) (element as ExternalIdInfo).key ?? "": ""},
);
state = state.copyWith(processing: false);
}
IdentifyModel update(IdentifyModel Function(IdentifyModel state) cb) => state = cb(state);
void clearFields() {
state = state.copyWith(
searchString: "",
year: () => null,
keys: state.keys..updateAll((key, value) => ""),
);
}
void updateKey(MapEntry<String, String> map) {
state = state.copyWith(keys: state.keys..update(map.key, (value) => map.value));
}
Future<Response<List<RemoteSearchResult>>?> remoteSearch() async {
if (state.item == null) return null;
state = state.copyWith(processing: true);
late Response<List<RemoteSearchResult>> response;
switch (state.item) {
case SeriesModel _:
response = await api.itemsRemoteSearchSeriesPost(body: SeriesInfoRemoteSearchQuery.fromJson(state.body));
case MovieModel _:
default:
response = await api.itemsRemoteSearchMoviePost(body: MovieInfoRemoteSearchQuery.fromJson(state.body));
}
state = state.copyWith(results: response.body, processing: false);
return response;
}
Future<Response<dynamic>?> setIdentity(RemoteSearchResult result) async {
if (state.item == null) return null;
state = state.copyWith(processing: true);
final response = await api.itemsRemoteSearchApplyItemIdPost(
itemId: state.item?.id ?? "", body: RemoteSearchResult.fromJson(result.toJson()));
state = state.copyWith(processing: false);
return response;
}
}

View file

@ -0,0 +1,47 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/information_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart';
class InformationProviderModel {
final InformationModel? model;
final bool loading;
InformationProviderModel({
this.model,
this.loading = false,
});
InformationProviderModel copyWith({
InformationModel? model,
bool? loading,
}) {
return InformationProviderModel(
model: model ?? this.model,
loading: loading ?? this.loading,
);
}
}
final informationProvider =
StateNotifierProvider.autoDispose.family<InformationNotifier, InformationProviderModel, String>((ref, id) {
return InformationNotifier(ref);
});
class InformationNotifier extends StateNotifier<InformationProviderModel> {
InformationNotifier(this.ref) : super(InformationProviderModel());
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response> getItemInformation(ItemBaseModel item) async {
state = state.copyWith(loading: true);
final response = await api.usersUserIdItemsItemIdGetBaseItem(itemId: item.id);
await Future.delayed(const Duration(milliseconds: 250));
state = state.copyWith(loading: false, model: InformationModel.fromResponse(response.body));
return response;
}
}

View file

@ -0,0 +1,22 @@
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final itemDetailsProvider = StateNotifierProvider.autoDispose<ItemDetailsNotifier, ItemBaseModel?>((ref) {
return ItemDetailsNotifier(ref);
});
class ItemDetailsNotifier extends StateNotifier<ItemBaseModel?> {
ItemDetailsNotifier(this.ref) : super(null);
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<ItemBaseModel?> fetchDetails(String itemId) async {
final response = await api.usersUserIdItemsItemIdGet(itemId: itemId);
if (response.body == null) return null;
return response.bodyOrThrow;
}
}

View file

@ -0,0 +1,51 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/related_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'movies_details_provider.g.dart';
@riverpod
class MovieDetails extends _$MovieDetails {
late final JellyService api = ref.read(jellyApiProvider);
@override
MovieModel? build(String arg) => null;
Future<Response?> fetchDetails(ItemBaseModel item) async {
try {
if (item is MovieModel && state == null) {
state = item;
}
final response = await api.usersUserIdItemsItemIdGet(itemId: item.id);
if (response.body == null) return null;
state = response.bodyOrThrow as MovieModel;
final related = await ref.read(relatedUtilityProvider).relatedContent(item.id);
state = state?.copyWith(related: related.body);
return null;
} catch (e) {
_tryToCreateOfflineState(item);
return null;
}
}
void _tryToCreateOfflineState(ItemBaseModel item) {
final syncNotifier = ref.read(syncProvider.notifier);
final syncedItem = syncNotifier.getParentItem(item.id);
if (syncedItem == null) return;
final movieModel = syncedItem.createItemModel(ref) as MovieModel;
state = movieModel;
}
void setSubIndex(int index) {
state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(defaultSubStreamIndex: index));
}
void setAudioIndex(int index) {
state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(defaultAudioStreamIndex: index));
}
}

View file

@ -0,0 +1,174 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'movies_details_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$movieDetailsHash() => r'e5ab0af7fab9eb7a8ea50a873e8875bb572bd240';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$MovieDetails
extends BuildlessAutoDisposeNotifier<MovieModel?> {
late final String arg;
MovieModel? build(
String arg,
);
}
/// See also [MovieDetails].
@ProviderFor(MovieDetails)
const movieDetailsProvider = MovieDetailsFamily();
/// See also [MovieDetails].
class MovieDetailsFamily extends Family<MovieModel?> {
/// See also [MovieDetails].
const MovieDetailsFamily();
/// See also [MovieDetails].
MovieDetailsProvider call(
String arg,
) {
return MovieDetailsProvider(
arg,
);
}
@override
MovieDetailsProvider getProviderOverride(
covariant MovieDetailsProvider provider,
) {
return call(
provider.arg,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'movieDetailsProvider';
}
/// See also [MovieDetails].
class MovieDetailsProvider
extends AutoDisposeNotifierProviderImpl<MovieDetails, MovieModel?> {
/// See also [MovieDetails].
MovieDetailsProvider(
String arg,
) : this._internal(
() => MovieDetails()..arg = arg,
from: movieDetailsProvider,
name: r'movieDetailsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$movieDetailsHash,
dependencies: MovieDetailsFamily._dependencies,
allTransitiveDependencies:
MovieDetailsFamily._allTransitiveDependencies,
arg: arg,
);
MovieDetailsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
}) : super.internal();
final String arg;
@override
MovieModel? runNotifierBuild(
covariant MovieDetails notifier,
) {
return notifier.build(
arg,
);
}
@override
Override overrideWith(MovieDetails Function() create) {
return ProviderOverride(
origin: this,
override: MovieDetailsProvider._internal(
() => create()..arg = arg,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
),
);
}
@override
AutoDisposeNotifierProviderElement<MovieDetails, MovieModel?>
createElement() {
return _MovieDetailsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is MovieDetailsProvider && other.arg == arg;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
return _SystemHash.finish(hash);
}
}
mixin MovieDetailsRef on AutoDisposeNotifierProviderRef<MovieModel?> {
/// The parameter `arg` of this provider.
String get arg;
}
class _MovieDetailsProviderElement
extends AutoDisposeNotifierProviderElement<MovieDetails, MovieModel?>
with MovieDetailsRef {
_MovieDetailsProviderElement(super.provider);
@override
String get arg => (origin as MovieDetailsProvider).arg;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,69 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/models/items/person_model.dart';
import 'package:fladder/models/items/series_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final personDetailsProvider =
StateNotifierProvider.autoDispose.family<PersonDetailsNotifier, PersonModel?, String>((ref, id) {
return PersonDetailsNotifier(ref);
});
class PersonDetailsNotifier extends StateNotifier<PersonModel?> {
PersonDetailsNotifier(this.ref) : super(null);
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response?> fetchPerson(Person person) async {
final response = await api.usersUserIdItemsItemIdGet(itemId: person.id);
if (response.isSuccessful && response.body != null) {
state = response.bodyOrThrow as PersonModel;
fetchMovies();
}
return response;
}
Future<Response?> fetchMovies() async {
final movies = await api.itemsGet(
personIds: [state?.id ?? ""],
limit: 25,
sortBy: [ItemSortBy.premieredate, ItemSortBy.communityrating, ItemSortBy.sortname, ItemSortBy.productionyear],
sortOrder: [SortOrder.descending],
recursive: true,
fields: [
ItemFields.primaryimageaspectratio,
],
includeItemTypes: [
BaseItemKind.movie,
],
);
final series = await api.itemsGet(
personIds: [state?.id ?? ""],
limit: 25,
sortBy: [ItemSortBy.premieredate, ItemSortBy.communityrating, ItemSortBy.sortname, ItemSortBy.productionyear],
sortOrder: [SortOrder.descending],
recursive: true,
fields: [
ItemFields.primaryimageaspectratio,
],
includeItemTypes: [
BaseItemKind.series,
],
);
state = state?.copyWith(
movies: movies.body?.items.whereType<MovieModel>().toList(),
series: series.body?.items.whereType<SeriesModel>().toList(),
);
return movies;
}
}

View file

@ -0,0 +1,49 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final photoDetailsProvider =
StateNotifierProvider.autoDispose.family<PhotoDetailsNotifier, PhotoAlbumModel?, String>((ref, id) {
return PhotoDetailsNotifier(ref);
});
class PhotoDetailsNotifier extends StateNotifier<PhotoAlbumModel?> {
PhotoDetailsNotifier(this.ref) : super(null);
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response?> fetchDetails(ItemBaseModel item) async {
String? albumId;
if (item is PhotoModel) {
albumId = item.albumId;
} else if (item is PhotoAlbumModel) {
albumId = item.id;
}
final albumData = await api.usersUserIdItemsItemIdGet(itemId: albumId);
if (albumData.body == null) return albumData;
PhotoAlbumModel newState = albumData.bodyOrThrow as PhotoAlbumModel;
final response = await api.itemsGet(
parentId: albumId,
fields: [ItemFields.primaryimageaspectratio],
sortBy: [ItemSortBy.sortname],
includeItemTypes: [
BaseItemKind.folder,
BaseItemKind.photoalbum,
BaseItemKind.photo,
BaseItemKind.video,
],
sortOrder: [SortOrder.ascending],
);
if (response.body == null) return null;
newState = newState.copyWith(photos: response.body?.items.toList());
state = newState;
return null;
}
}

View file

@ -0,0 +1,33 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final seasonDetailsProvider =
StateNotifierProvider.autoDispose.family<SeasonDetailsNotifier, SeasonModel?, String>((ref, id) {
return SeasonDetailsNotifier(ref);
});
class SeasonDetailsNotifier extends StateNotifier<SeasonModel?> {
SeasonDetailsNotifier(this.ref) : super(null);
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response?> fetchDetails(String seasonId) async {
SeasonModel? newState;
final season = await api.usersUserIdItemsItemIdGet(itemId: seasonId);
if (season.body != null) newState = season.bodyOrThrow as SeasonModel;
final episodes = await api.showsSeriesIdEpisodesGet(
seriesId: newState?.seriesId ?? "", seasonId: seasonId, fields: [ItemFields.overview]);
newState = newState?.copyWith(episodes: EpisodeModel.episodesFromDto(episodes.body?.items, ref));
state = newState;
return season;
}
}

View file

@ -0,0 +1,95 @@
import 'package:chopper/chopper.dart';
import 'package:collection/collection.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/models/items/series_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/related_provider.dart';
final seriesDetailsProvider =
StateNotifierProvider.autoDispose.family<SeriesDetailViewNotifier, SeriesModel?, String>((ref, id) {
return SeriesDetailViewNotifier(ref);
});
class SeriesDetailViewNotifier extends StateNotifier<SeriesModel?> {
SeriesDetailViewNotifier(this.ref) : super(null);
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response?> fetchDetails(ItemBaseModel seriesModel) async {
try {
if (seriesModel is SeriesModel) {
state = seriesModel;
}
SeriesModel? newState;
final response = await api.usersUserIdItemsItemIdGet(itemId: seriesModel.id);
if (response.body == null) {
state = seriesModel as SeriesModel;
return null;
}
newState = response.bodyOrThrow as SeriesModel;
final seasons = await api.showsSeriesIdSeasonsGet(seriesId: seriesModel.id);
newState = newState.copyWith(seasons: SeasonModel.seasonsFromDto(seasons.body?.items, ref));
final episodes = await api.showsSeriesIdEpisodesGet(seriesId: seriesModel.id, fields: [
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.overview,
]);
newState = newState.copyWith(
availableEpisodes: EpisodeModel.episodesFromDto(
episodes.body?.items,
ref,
),
);
final related = await ref.read(relatedUtilityProvider).relatedContent(seriesModel.id);
state = newState.copyWith(related: related.body);
return response;
} catch (e) {
_tryToCreateOfflineState(seriesModel);
return null;
}
}
Future<void> _tryToCreateOfflineState(ItemBaseModel series) async {
final syncNotifier = ref.read(syncProvider.notifier);
final syncedItem = syncNotifier.getSyncedItem(series);
if (syncedItem == null) return;
final seriesModel = syncedItem.createItemModel(ref) as SeriesModel;
final allChildren = syncedItem
.getNestedChildren(ref)
.map(
(e) => e.createItemModel(ref),
)
.whereNotNull()
.toList();
state = seriesModel.copyWith(
availableEpisodes: allChildren.whereType<EpisodeModel>().toList(),
seasons: allChildren.whereType<SeasonModel>().toList(),
);
return;
}
void updateEpisodeInfo(EpisodeModel episode) {
final index = state?.availableEpisodes?.indexOf(episode);
if (index != null) {
state = state?.copyWith(
availableEpisodes: state?.availableEpisodes
?..remove(episode)
..insert(index, episode),
);
}
}
}

View file

@ -0,0 +1,174 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/models/library_model.dart';
import 'package:fladder/models/recommended_model.dart';
import 'package:fladder/models/view_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
bool _useFolders(ViewModel model) {
switch (model.collectionType) {
case CollectionType.boxsets:
case CollectionType.homevideos:
case CollectionType.folders:
return true;
default:
return false;
}
}
final libraryProvider = StateNotifierProvider.autoDispose.family<LibraryNotifier, LibraryModel?, String>((ref, id) {
return LibraryNotifier(ref);
});
class LibraryNotifier extends StateNotifier<LibraryModel?> {
LibraryNotifier(this.ref) : super(null);
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
set loading(bool value) {
state = state?.copyWith(loading: value);
}
bool get loading => state?.loading ?? true;
Future<void> setupLibrary(ViewModel viewModel) async {
state ??= LibraryModel(id: viewModel.id, name: viewModel.name, loading: true, type: BaseItemKind.movie);
}
Future<Response?> loadLibrary(ViewModel viewModel) async {
final response = await api.itemsGet(
parentId: viewModel.id,
sortBy: [ItemSortBy.sortname, ItemSortBy.productionyear],
isMissing: false,
excludeItemTypes: !_useFolders(viewModel) ? [BaseItemKind.folder] : [],
fields: [ItemFields.genres, ItemFields.childcount, ItemFields.parentid],
);
state = state?.copyWith(posters: response.body?.items);
loading = false;
return response;
}
Future<void> loadRecommendations(ViewModel viewModel) async {
loading = true;
//Clear recommendations because of all the copying
state = state?.copyWith(recommendations: []);
final latest = await api.usersUserIdItemsLatestGet(
parentId: viewModel.id,
limit: 14,
isPlayed: false,
imageTypeLimit: 1,
includeItemTypes: viewModel.collectionType == CollectionType.tvshows ? [BaseItemKind.episode] : null,
);
state = state?.copyWith(
recommendations: [
...?state?.recommendations,
RecommendedModel(
name: "Latest",
posters: latest.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList() ?? [],
type: "Latest",
),
],
);
if (viewModel.collectionType == CollectionType.movies) {
final response = await api.moviesRecommendationsGet(
parentId: viewModel.id,
categoryLimit: 6,
itemLimit: 8,
fields: [ItemFields.mediasourcecount],
);
state = state?.copyWith(recommendations: [
...?state?.recommendations,
...response.body?.map(
(e) => RecommendedModel(
name: e.baselineItemName ?? "",
posters: e.items?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList() ?? [],
type: e.recommendationType.toString(),
),
) ??
[],
]);
loading = false;
} else {
final nextUp = await api.showsNextUpGet(
parentId: viewModel.id,
limit: 14,
imageTypeLimit: 1,
fields: [ItemFields.mediasourcecount, ItemFields.primaryimageaspectratio],
);
state = state?.copyWith(recommendations: [
...?state?.recommendations,
...[
RecommendedModel(
name: "Next up",
posters: nextUp.body?.items
?.map(
(e) => ItemBaseModel.fromBaseDto(
e,
ref,
),
)
.toList() ??
[],
type: "Latest series")
],
]);
loading = false;
}
}
Future<Response?> loadFavourites(ViewModel viewModel) async {
loading = true;
final response = await api.itemsGet(
parentId: viewModel.id,
isFavorite: true,
recursive: true,
);
state = state?.copyWith(favourites: response.body?.items);
loading = false;
return response;
}
Future<Response?> loadTimeline(ViewModel viewModel) async {
loading = true;
final response = await api.itemsGet(
parentId: viewModel.id,
recursive: true,
fields: [ItemFields.primaryimageaspectratio, ItemFields.datecreated],
sortBy: [ItemSortBy.datecreated],
sortOrder: [SortOrder.descending],
includeItemTypes: [
BaseItemKind.photo,
BaseItemKind.video,
],
);
state = state?.copyWith(
timelinePhotos: response.body?.items.map((e) => e as PhotoModel).toList(),
);
loading = false;
return response;
}
Future<Response?> loadGenres(ViewModel viewModel) async {
final genres = await api.genresGet(
sortBy: [ItemSortBy.sortname],
sortOrder: [SortOrder.ascending],
includeItemTypes: viewModel.collectionType == CollectionType.movies
? [BaseItemKind.movie]
: [
BaseItemKind.series,
],
parentId: viewModel.id,
);
state = state?.copyWith(
genres: genres.body?.items?.where((element) => element.name?.isNotEmpty ?? false).map((e) => e.name!).toList());
return null;
}
}

View file

@ -0,0 +1,670 @@
import 'dart:developer';
import 'package:chopper/chopper.dart';
import 'package:collection/collection.dart';
import 'package:fladder/models/collection_types.dart';
import 'package:fladder/models/items/folder_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/models/library_search/library_search_options.dart';
import 'package:fladder/models/playlist_model.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
import 'package:fladder/util/list_extensions.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/library_search/library_search_model.dart';
import 'package:fladder/models/view_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/util/map_bool_helper.dart';
import 'package:page_transition/page_transition.dart';
final librarySearchProvider =
StateNotifierProvider.family.autoDispose<LibrarySearchNotifier, LibrarySearchModel, Key>((ref, id) {
return LibrarySearchNotifier(ref);
});
class LibrarySearchNotifier extends StateNotifier<LibrarySearchModel> {
LibrarySearchNotifier(this.ref) : super(LibrarySearchModel());
final Ref ref;
int get pageSize => ref.read(clientSettingsProvider).libraryPageSize ?? 500;
late final JellyService api = ref.read(jellyApiProvider);
set loading(bool loading) => state = state.copyWith(loading: loading);
bool loadedFilters = false;
bool get loading => state.loading;
Future<void> initRefresh(
List<String>? folderId,
String? viewModelId,
bool? favourites,
) async {
loading = true;
state = state.resetLazyLoad();
if (state.views.isEmpty && state.folderOverwrite.isEmpty) {
if (folderId != null) {
await loadFolders(folderId: folderId);
} else {
await loadViews(viewModelId, favourites);
}
}
await loadFilters();
await loadMore(init: true);
loading = false;
}
Future<void> loadMore({bool? init}) async {
if ((loading && init != true) || state.allDoneFetching) return;
loading = true;
final newLastIndices = Map<String, int>.from(state.lastIndices);
final newLibraryItemCounts = Map<String, int>.from(state.libraryItemCounts);
final isEmpty = newLastIndices.isEmpty;
Future<void> handleItemLoading(String itemId, ItemBaseModel currentModel) async {
final lastIndices = newLastIndices[itemId];
final libraryTotalCount = newLibraryItemCounts[itemId];
if (libraryTotalCount != null && lastIndices != null && libraryTotalCount <= lastIndices) return;
final result = currentModel is PlaylistModel
? await _loadPlaylistItems(id: itemId, startIndex: lastIndices, limit: pageSize)
: await _loadLibrary(id: itemId, startIndex: lastIndices, limit: pageSize);
if (result != null) {
newLibraryItemCounts[itemId] = result.totalRecordCount ?? 0;
newLastIndices[itemId] = (lastIndices ?? 0) + result.items.length;
state = state.copyWith(
posters: isEmpty ? result.items : [...state.posters, ...result.items],
lastIndices: newLastIndices,
libraryItemCounts: newLibraryItemCounts,
);
}
}
Future<void> handleViewLoading() async {
final results = await Future.wait(
state.views.included.map((viewModel) async {
final lastIndices = newLastIndices[viewModel.id];
final libraryTotalCount = newLibraryItemCounts[viewModel.id];
if (libraryTotalCount != null && lastIndices != null && libraryTotalCount <= lastIndices) return null;
final libraryItems = await _loadLibrary(
viewModel: viewModel,
startIndex: lastIndices,
limit: pageSize ~/ state.views.included.length,
);
if (libraryItems != null) {
newLibraryItemCounts[viewModel.id] = libraryItems.totalRecordCount ?? 0;
newLastIndices[viewModel.id] = (lastIndices ?? 0) + libraryItems.items.length;
}
return libraryItems;
}).whereNotNull(),
);
List<ItemBaseModel> newPosters = results.whereNotNull().expand((element) => element.items).toList();
if (state.views.included.length > 1) {
if (state.sortingOption == SortingOptions.random) {
newPosters = newPosters.random();
} else {
newPosters = newPosters.sorted(
(a, b) => sortItems(a, b, state.sortingOption, state.sortOrder),
);
}
}
state = state.copyWith(
posters: isEmpty ? newPosters : [...state.posters, ...newPosters],
lastIndices: newLastIndices,
libraryItemCounts: newLibraryItemCounts,
);
}
if (state.folderOverwrite.isNotEmpty) {
await handleItemLoading(state.folderOverwrite.last.id, state.folderOverwrite.last);
} else if (state.views.hasEnabled) {
await handleViewLoading();
} else {
if (state.searchQuery.isEmpty && !state.favourites) {
state = state.copyWith(posters: []);
} else {
final response = await _loadLibrary(recursive: true);
state = state.copyWith(posters: response?.items ?? []);
}
}
loading = false;
}
//Pas viewmodel otherwise select first
Future<void> loadViews(String? viewModelId, bool? favourites) async {
final response = await api.usersUserIdViewsGet(includeHidden: false);
final createdViews = response.body?.items?.map((e) => ViewModel.fromBodyDto(e, ref));
Map<ViewModel, bool> mappedModels =
createdViews?.isNotEmpty ?? false ? {for (var element in createdViews!) element: false} : {};
final selectedModel = mappedModels.keys.firstWhereOrNull((element) => element.id == viewModelId);
state = state.copyWith(
views: selectedModel != null
? mappedModels.setKey(mappedModels.keys.firstWhere((element) => element.id == viewModelId), true)
: mappedModels,
favourites: favourites,
);
}
Future<void> loadFolders({List<String>? folderId}) async {
final response = await api.itemsGet(
ids: folderId ?? state.folderOverwrite.map((e) => e.id).toList(),
sortBy: state.sortingOption.toSortBy,
sortOrder: [state.sortOrder.sortOrder],
fields: [
ItemFields.parentid,
ItemFields.primaryimageaspectratio,
],
);
state = state.copyWith(folderOverwrite: response.body?.items.toList());
}
Future<void> loadFilters() async {
if (loadedFilters == true) return;
loadedFilters = true;
final enabledCollections = state.views.included.map((e) => e.collectionType.itemKinds).expand((element) => element);
final mappedList = await Future.wait(state.views.included.map((viewModel) => _loadFilters(viewModel)));
final studios = (await Future.wait(state.views.included.map((viewModel) => _loadStudios(viewModel))))
.expand((element) => element)
.toSet()
.toList();
var tempState = state.copyWith();
final genres = mappedList
.expand((element) => element?.genres ?? <NameGuidPair>[])
.whereNotNull()
.sorted((a, b) => a.name!.toLowerCase().compareTo(b.name!.toLowerCase()));
final tags = mappedList
.expand((element) => element?.tags ?? <String>[])
.sorted((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
tempState = tempState.copyWith(
types: state.types.setAll(false).setKeys(enabledCollections, true),
genres: {for (var element in genres) element.name!: false}.replaceMap(tempState.genres),
studios: {for (var element in studios) element: false}.replaceMap(tempState.studios),
tags: {for (var element in tags) element: false}.replaceMap(tempState.tags),
);
state = tempState;
}
Future<QueryFilters?> _loadFilters(ViewModel viewModel) async {
final response = await api.itemsFilters2Get(parentId: viewModel.id);
return response.body;
}
Future<List<Studio>> _loadStudios(ViewModel viewModel) async {
final response = await api.studiosGet(parentId: viewModel.id);
return response.body?.items?.map((e) => Studio(id: e.id ?? "", name: e.name ?? "")).toList() ?? [];
}
Future<ServerQueryResult?> _loadLibrary(
{ViewModel? viewModel,
bool? recursive,
bool? shuffle,
String? id,
int? limit,
int? startIndex,
String? searchTerm}) async {
final searchString = searchTerm ?? (state.searchQuery.isNotEmpty ? state.searchQuery : null);
final response = await api.itemsGet(
parentId: viewModel?.id ?? id,
searchTerm: searchString,
genres: state.genres.included,
tags: state.tags.included,
recursive: searchString?.isNotEmpty == true ? true : recursive ?? state.recursive,
officialRatings: state.officialRatings.included,
years: state.years.included,
isMissing: false,
limit: (limit ?? 0) > 0 ? limit : null,
startIndex: (limit ?? 0) > 0 ? startIndex : null,
collapseBoxSetItems: false,
studioIds: state.studios.included.map((e) => e.id).toList(),
sortBy: shuffle == true ? [ItemSortBy.random] : state.sortingOption.toSortBy,
sortOrder: [state.sortOrder.sortOrder],
fields: {
ItemFields.genres,
ItemFields.parentid,
ItemFields.tags,
ItemFields.datecreated,
ItemFields.datelastmediaadded,
ItemFields.overview,
ItemFields.originaltitle,
ItemFields.customrating,
ItemFields.primaryimageaspectratio,
if (viewModel?.collectionType == CollectionType.tvshows) ItemFields.childcount,
}.toList(),
filters: [
...state.filters.included,
if (state.favourites) ItemFilter.isfavorite,
],
includeItemTypes: state.types.included.map((e) => e.dtoKind).toList(),
);
return response.body;
}
Future<ServerQueryResult?> _loadPlaylistItems({ViewModel? viewModel, String? id, int? startIndex, int? limit}) async {
final response = await api.playlistsPlaylistIdItemsGet(
playlistId: viewModel?.id ?? id,
limit: (limit ?? 0) > 0 ? limit : null,
startIndex: (limit ?? 0) > 0 ? startIndex : null,
fields: {
ItemFields.genres,
ItemFields.parentid,
ItemFields.tags,
ItemFields.datecreated,
ItemFields.datelastmediaadded,
ItemFields.overview,
ItemFields.originaltitle,
ItemFields.customrating,
ItemFields.primaryimageaspectratio,
if (viewModel?.collectionType == CollectionType.tvshows) ItemFields.childcount,
}.toList(),
);
return response.body;
}
Future<List<ItemBaseModel>> fetchSuggestions(String searchTerm) async {
if (state.folderOverwrite.isNotEmpty) {
final response = await _loadLibrary(id: state.nestedCurrentItem?.id ?? "", searchTerm: searchTerm, limit: 25);
return response?.items ?? [];
} else {
if (state.views.hasEnabled) {
final mappedList = await Future.wait(state.views.included
.map((viewModel) => _loadLibrary(viewModel: viewModel, limit: 25, searchTerm: searchTerm)));
return mappedList
.expand((innerList) => innerList?.items ?? [])
.where((item) => item != null)
.cast<ItemBaseModel>()
.toList();
} else {
if (searchTerm.isEmpty) {
return [];
} else {
final response = await _loadLibrary(limit: 25, recursive: true, searchTerm: searchTerm);
return response?.items ?? [];
}
}
}
}
void setSearch(String query) {
state = state.copyWith(searchQuery: query);
ref.read(userProvider.notifier).addSearchQuery(query);
}
void toggleFavourite() => state = state.copyWith(favourites: !state.favourites);
void toggleRecursive() => state = state.copyWith(recursive: !state.recursive);
void toggleType(FladderItemType type) => state = state.copyWith(types: state.types.toggleKey(type));
void toggleView(ViewModel view) => state = state.copyWith(views: state.views.toggleKey(view));
void toggleGenre(String genre) => state = state.copyWith(genres: state.genres.toggleKey(genre));
void toggleStudio(Studio studio) => state = state.copyWith(studios: state.studios.toggleKey(studio));
void toggleTag(String tag) => state = state.copyWith(tags: state.tags.toggleKey(tag));
void toggleRatings(String officialRatings) =>
state = state.copyWith(officialRatings: state.officialRatings.toggleKey(officialRatings));
void toggleYears(int year) => state = state.copyWith(years: state.years.toggleKey(year));
void toggleFilters(ItemFilter filter) => state = state.copyWith(filters: state.filters.toggleKey(filter));
void setViews(Map<ViewModel, bool> views) {
loadedFilters = false;
state = state.copyWith(views: views).setFiltersToDefault();
}
void setGenres(Map<String, bool> genres) => state = state.copyWith(genres: genres);
void setStudios(Map<Studio, bool> studios) => state = state.copyWith(studios: studios);
void setTags(Map<String, bool> tags) => state = state.copyWith(tags: tags);
void setTypes(Map<FladderItemType, bool> types) => state = state.copyWith(types: types);
void setRatings(Map<String, bool> officialRatings) => state = state.copyWith(officialRatings: officialRatings);
void setYears(Map<int, bool> years) => state = state.copyWith(years: years);
void setFilters(Map<ItemFilter, bool> filters) => state = state.copyWith(filters: filters);
void clearAllFilters() {
state = state.copyWith(
genres: state.genres.setAll(false),
tags: state.tags.setAll(false),
officialRatings: state.officialRatings.setAll(false),
years: state.years.setAll(false),
searchQuery: '',
favourites: false,
recursive: false,
studios: state.studios.setAll(false),
filters: state.filters.setAll(false),
hideEmtpyShows: false,
);
}
void setSortBy(SortingOptions e) => state = state.copyWith(sortingOption: e);
void setSortOrder(SortingOrder e) => state = state.copyWith(sortOrder: e);
void setHideEmpty(bool value) => state = state.copyWith(hideEmtpyShows: value);
void setGroupBy(GroupBy groupBy) => state = state.copyWith(groupBy: groupBy);
void setFolderId(ItemBaseModel item) {
if (state.folderOverwrite.contains(item)) return;
state = state.copyWith(folderOverwrite: [...state.folderOverwrite, item]);
}
void backToFolder(ItemBaseModel item) => state = state.copyWith(
folderOverwrite: state.folderOverwrite.getRange(0, state.folderOverwrite.indexOf(item) + 1).toList());
void clearFolderOverWrite() => state = state.copyWith(folderOverwrite: []);
void toggleSelectMode() =>
state = state.copyWith(selecteMode: !state.selecteMode, selectedPosters: !state.selecteMode == false ? [] : null);
void toggleSelection(ItemBaseModel item) {
if (state.selectedPosters.contains(item)) {
state = state.copyWith(selectedPosters: state.selectedPosters.where((element) => element != item).toList());
} else {
state = state.copyWith(selectedPosters: [...state.selectedPosters, item]);
}
}
selectAll(bool select) => state = state.copyWith(selectedPosters: select ? state.posters : []);
Future<void> setSelectedAsFavorite(bool bool) async {
final Map<String, UserData> updateInfo = {};
for (var i = 0; i < state.selectedPosters.length; i++) {
final response = await ref.read(userProvider.notifier).setAsFavorite(bool, state.selectedPosters[i].id);
final userData = response?.bodyOrThrow;
if (userData != null) {
updateInfo.putIfAbsent(state.selectedPosters[i].id, () => userData);
}
}
updateMultiUserData(updateInfo);
}
Future<void> setSelectedAsWatched(bool bool) async {
final Map<String, UserData> updateInfo = {};
for (var i = 0; i < state.selectedPosters.length; i++) {
final response = await ref.read(userProvider.notifier).markAsPlayed(bool, state.selectedPosters[i].id);
final userData = response?.bodyOrThrow;
if (userData != null) {
updateInfo.putIfAbsent(state.selectedPosters[i].id, () => userData);
}
}
updateMultiUserData(updateInfo);
}
Future<Response> removeSelectedFromCollection() async {
final response = await api.collectionsCollectionIdItemsDelete(
collectionId: state.nestedCurrentItem?.id, ids: state.selectedPosters.map((e) => e.id).toList());
if (response.isSuccessful) {
removeFromPosters([state.nestedCurrentItem?.id].whereNotNull().toList());
}
return response;
}
Future<Response> removeSelectedFromPlaylist() async {
final response = await api.playlistsPlaylistIdItemsDelete(
playlistId: state.nestedCurrentItem?.id,
entryIds: state.selectedPosters.map((e) => e.playlistId).whereNotNull().toList());
if (response.isSuccessful) {
removeFromPosters([state.nestedCurrentItem?.id].whereNotNull().toList());
}
return response;
}
Future<Response> removeFromCollection({required List<ItemBaseModel> items}) async {
final response = await api.collectionsCollectionIdItemsDelete(
collectionId: state.nestedCurrentItem?.id, ids: items.map((e) => e.id).toList());
if (response.isSuccessful) {
removeFromPosters(items.map((e) => e.id).toList());
}
return response;
}
Future<Response> removeFromPlaylist({required List<ItemBaseModel> items}) async {
final response = await api.playlistsPlaylistIdItemsDelete(
playlistId: state.nestedCurrentItem?.id, entryIds: items.map((e) => e.playlistId).whereNotNull().toList());
if (response.isSuccessful) {
removeFromPosters(items.map((e) => e.id).toList());
}
return response;
}
Future<void> updateMultiUserData(Map<String, UserData?> newData) async {
for (var element in newData.entries) {
updateUserData(element.key, element.value);
}
}
Future<void> updateUserData(String id, UserData? newData) async {
final currentItems = state.posters.toList();
final item = currentItems.firstWhereOrNull((element) => element.id == id);
if (item == null) return;
final indexOf = currentItems.indexOf(item);
if (indexOf == -1) return;
currentItems.removeAt(indexOf);
currentItems.insert(indexOf, item.copyWith(userData: newData));
state = state.copyWith(posters: currentItems);
}
void setDefaultOptions(SortingOrder? sortOrder, SortingOptions? sortingOptions) {
state = state.copyWith(
sortOrder: sortOrder,
sortingOption: sortingOptions,
);
}
void updateUserDataMain(UserData? userData) {
state = state.copyWith(
folderOverwrite: [state.folderOverwrite.lastOrNull?.copyWith(userData: userData)].whereNotNull().toList(),
);
}
void updateParentItem(ItemBaseModel item) {
state = state.copyWith(
folderOverwrite: [item],
);
}
void removeFromPosters(List<String> ids) {
final newPosters = state.posters;
state = state.copyWith(posters: newPosters..removeWhere((element) => ids.contains(element.id)));
}
void updateItems(List<ItemBaseModel> items) {}
void updateItem(ItemBaseModel item) {
state = state.copyWith(posters: state.posters.replace(item));
}
Future<List<ItemBaseModel>> _loadAllItems({bool shuffle = false, int? limit}) async {
List<ItemBaseModel> itemsToPlay = [];
Future<void> handleItemLoading(String itemId, ItemBaseModel currentModel) async {
final result =
currentModel is PlaylistModel ? await _loadPlaylistItems(id: itemId) : await _loadLibrary(id: itemId);
itemsToPlay = result?.items ?? [];
}
Future<void> handleViewLoading() async {
final results = await Future.wait(
state.views.included.map((viewModel) async {
final libraryItems = await _loadLibrary(
shuffle: shuffle,
viewModel: viewModel,
limit: limit,
);
return libraryItems;
}).whereNotNull(),
);
List<ItemBaseModel> newPosters = results.whereNotNull().expand((element) => element.items).toList();
if (state.views.included.length > 1) {
if (shuffle || state.sortingOption == SortingOptions.random) {
newPosters = newPosters.random();
} else {
newPosters = newPosters.sorted(
(a, b) => sortItems(a, b, state.sortingOption, state.sortOrder),
);
}
}
itemsToPlay = newPosters;
}
if (state.folderOverwrite.isNotEmpty) {
await handleItemLoading(state.folderOverwrite.last.id, state.folderOverwrite.last);
} else if (state.views.hasEnabled) {
await handleViewLoading();
} else {
if (state.searchQuery.isEmpty && !state.favourites) {
itemsToPlay = [];
} else {
final response = await _loadLibrary(recursive: true, shuffle: shuffle);
itemsToPlay = response?.items ?? [];
}
}
return itemsToPlay;
}
Future<void> playLibraryItems(BuildContext context, WidgetRef ref, {bool shuffle = false}) async {
state = state.copyWith(fetchingItems: true);
List<ItemBaseModel> itemsToPlay = [];
if (state.selectedPosters.isNotEmpty) {
itemsToPlay = shuffle ? state.selectedPosters.random() : state.selectedPosters;
} else {
itemsToPlay = await _loadAllItems(shuffle: shuffle);
}
state = state.copyWith(fetchingItems: false);
if (itemsToPlay.isNotEmpty) {
await itemsToPlay.playLibraryItems(context, ref);
} else {
fladderSnackbar(context, title: context.localized.libraryFetchNoItemsFound);
}
}
Future<List<PhotoModel>> fetchGallery({bool shuffle = false}) async {
try {
List<ItemBaseModel> itemsToPlay = [];
if (state.selectedPosters.isNotEmpty) {
itemsToPlay = shuffle ? state.selectedPosters.random() : state.selectedPosters;
} else {
itemsToPlay = await _loadAllItems(shuffle: shuffle);
}
List<PhotoModel> albumItems = [];
if (!state.types.included.containsAny([FladderItemType.video, FladderItemType.photo]) && state.recursive) {
for (var album in itemsToPlay.where(
(element) => element is PhotoAlbumModel || element is FolderModel,
)) {
try {
final fetchedAlbumContent = await api.itemsGet(
parentId: album.id,
includeItemTypes: [BaseItemKind.video, BaseItemKind.photo],
recursive: true,
fields: {
ItemFields.genres,
ItemFields.parentid,
ItemFields.tags,
ItemFields.datecreated,
ItemFields.datelastmediaadded,
ItemFields.overview,
ItemFields.originaltitle,
ItemFields.customrating,
ItemFields.primaryimageaspectratio,
}.toList(),
filters: [
...state.filters.included,
if (state.favourites) ItemFilter.isfavorite,
],
sortBy: shuffle ? [ItemSortBy.random] : null,
);
albumItems.addAll(fetchedAlbumContent.body?.items.whereType<PhotoModel>() ?? []);
} catch (e) {
log("Error fetching ${e.toString()}");
}
}
}
final galleryItems = itemsToPlay.whereType<PhotoModel>().toList();
if (shuffle) {
albumItems = albumItems.random();
}
final allItems = {...albumItems.whereType<PhotoModel>(), ...galleryItems}.toList();
return allItems;
} catch (e) {
log(e.toString());
} finally {}
return [];
}
Future<void> viewGallery(BuildContext context, {PhotoModel? selected, bool shuffle = false}) async {
state = state.copyWith(fetchingItems: true);
final allItems = await fetchGallery(shuffle: shuffle);
if (allItems.isNotEmpty) {
if (state.fetchingItems == true) {
state = state.copyWith(fetchingItems: false);
await Navigator.of(context, rootNavigator: true).push(
PageTransition(
child: PhotoViewerScreen(
items: allItems,
indexOfSelected: selected != null ? allItems.indexOf(selected) : 0,
),
type: PageTransitionType.fade),
);
}
} else {
fladderSnackbar(context, title: context.localized.libraryFetchNoItemsFound);
}
state = state.copyWith(fetchingItems: false);
}
void cancelFetch() {
state = state.copyWith(fetchingItems: false);
}
Future<void> openRandom(BuildContext context) async {
final items = await _loadAllItems(shuffle: true, limit: 1);
if (items.isNotEmpty) {
items.firstOrNull?.navigateTo(context);
}
}
}
extension SimpleSorter on List<ItemBaseModel> {
List<ItemBaseModel> hideEmptyChildren(bool hide) {
if (hide) {
return where((element) {
if (element.childCount == null) {
return true;
}
return (element.childCount ?? 0) > 0;
}).toList();
} else {
return this;
}
}
}

View file

@ -0,0 +1,83 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/playlist_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class _PlaylistProviderModel {
final List<ItemBaseModel> items;
final Map<PlaylistModel, bool?> collections;
_PlaylistProviderModel({
required this.items,
required this.collections,
});
_PlaylistProviderModel copyWith({
List<ItemBaseModel>? items,
Map<PlaylistModel, bool?>? collections,
}) {
return _PlaylistProviderModel(
items: items ?? this.items,
collections: collections ?? this.collections,
);
}
}
final playlistProvider = StateNotifierProvider.autoDispose<BoxSetNotifier, _PlaylistProviderModel>((ref) {
return BoxSetNotifier(ref);
});
class BoxSetNotifier extends StateNotifier<_PlaylistProviderModel> {
BoxSetNotifier(this.ref) : super(_PlaylistProviderModel(items: [], collections: {}));
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<void> setItems(List<ItemBaseModel> items) async {
state = state.copyWith(items: items);
return _init();
}
Future<void> _init() async {
final serverPlaylists = await api.usersUserIdItemsGet(
recursive: true,
includeItemTypes: [
BaseItemKind.playlist,
],
);
final playlists = serverPlaylists.body?.items?.map((e) => PlaylistModel.fromBaseDto(e, ref)).toList();
if (state.items.length == 1 && (playlists?.length ?? 0) < 25) {
final List<Future<bool>> itemChecks = playlists?.map((element) async {
final itemList = await api.usersUserIdItemsGet(parentId: element.id);
final List<String?> items = (itemList.body?.items ?? []).map((e) => e.id).toList();
return items.contains(state.items.firstOrNull?.id);
}).toList() ??
[];
final List<bool> results = await Future.wait(itemChecks);
final Map<PlaylistModel, bool?> boxSetContainsItemMap = Map.fromIterables(playlists ?? [], results);
state = state.copyWith(collections: boxSetContainsItemMap);
} else {
final Map<PlaylistModel, bool?> playlistContainsMap =
Map.fromIterables(playlists ?? [], List.generate(playlists?.length ?? 0, (index) => null));
state = state.copyWith(collections: playlistContainsMap);
}
}
Future<Response> addToPlaylist({required PlaylistModel playlist}) async =>
await api.playlistsPlaylistIdItemsPost(playlistId: playlist.id, ids: state.items.map((e) => e.id).toList());
Future<Response> addToNewPlaylist({required String name}) async {
final result = await api.playlistsPost(name: name, ids: state.items.map((e) => e.id).toList(), body: null);
if (result.isSuccessful) {
await _init();
}
return result;
}
}

View file

@ -0,0 +1,27 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final relatedUtilityProvider = Provider<RelatedNotifier>((ref) {
return RelatedNotifier(ref: ref);
});
class RelatedNotifier {
final Ref ref;
RelatedNotifier({
required this.ref,
});
late final JellyService api = ref.read(jellyApiProvider);
late final String currentServerUrl = ref.read(userProvider)?.server ?? "";
Future<Response<List<ItemBaseModel>>> relatedContent(String itemId) async {
final related = await api.itemsItemIdSimilarGet(itemId: itemId, limit: 50);
List<ItemBaseModel> posters = related.body?.items?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList() ?? [];
return Response(related.base, posters);
}
}

View file

@ -0,0 +1,42 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/models/search_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final searchProvider = StateNotifierProvider<SearchNotifier, SearchModel>((ref) {
return SearchNotifier(ref);
});
class SearchNotifier extends StateNotifier<SearchModel> {
SearchNotifier(this.ref) : super(SearchModel());
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response?> searchQuery() async {
if (state.searchQuery.isEmpty) return null;
state = state.copyWith(loading: true);
final response = await api.itemsGet(
recursive: true,
searchTerm: state.searchQuery,
);
state = state.copyWith(
resultCount: response.body?.totalRecordCount ?? 0,
results: (response.body?.items)?.groupedItems,
);
state = state.copyWith(loading: false);
return response;
}
void setQuery(String searchQuery) {
state = state.copyWith(searchQuery: searchQuery);
}
void clear() {
state = SearchModel();
}
}

View file

@ -0,0 +1,938 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:chopper/chopper.dart';
import 'package:collection/collection.dart';
import 'package:fladder/jellyfin/enum_models.dart';
import 'package:fladder/models/credentials_model.dart';
import 'package:fladder/models/items/intro_skip_model.dart';
import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/providers/image_provider.dart';
import 'package:fladder/util/duration_extensions.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/util/jellyfin_extension.dart';
import 'package:path/path.dart';
final jellyServiceProvider = StateProvider<JellyService>(
(ref) => JellyService(
ref,
JellyfinOpenApi.create(
interceptors: [
JellyRequest(ref),
JellyResponse(ref),
HttpLoggingInterceptor(level: Level.basic),
],
),
),
);
class ServerQueryResult {
final List<BaseItemDto> original;
final List<ItemBaseModel> items;
final int? totalRecordCount;
final int? startIndex;
ServerQueryResult({
required this.original,
required this.items,
this.totalRecordCount,
this.startIndex,
});
factory ServerQueryResult.fromBaseQuery(
BaseItemDtoQueryResult baseQuery,
Ref ref,
) {
return ServerQueryResult(
original: baseQuery.items ?? [],
items: baseQuery.items
?.map(
(e) => ItemBaseModel.fromBaseDto(e, ref),
)
.toList() ??
[],
totalRecordCount: baseQuery.totalRecordCount,
startIndex: baseQuery.startIndex,
);
}
ServerQueryResult copyWith({
List<BaseItemDto>? original,
List<ItemBaseModel>? items,
int? totalRecordCount,
int? startIndex,
}) {
return ServerQueryResult(
original: original ?? this.original,
items: items ?? this.items,
totalRecordCount: totalRecordCount ?? this.totalRecordCount,
startIndex: startIndex ?? this.startIndex,
);
}
}
class JellyService {
JellyService(this.ref, this.api);
final JellyfinOpenApi api;
final Ref ref;
AccountModel? get account => ref.read(userProvider);
Future<Response<ItemBaseModel>> usersUserIdItemsItemIdGet({
String? itemId,
}) async {
final response = await api.itemsItemIdGet(
userId: account?.id,
itemId: itemId,
);
return response.copyWith(body: ItemBaseModel.fromBaseDto(response.bodyOrThrow, ref));
}
Future<Response<BaseItemDto>> usersUserIdItemsItemIdGetBaseItem({
String? itemId,
}) async {
final response = await api.itemsItemIdGet(
userId: account?.id,
itemId: itemId,
);
return response;
}
Future<Response<ServerQueryResult>> itemsGet({
String? maxOfficialRating,
bool? hasThemeSong,
bool? hasThemeVideo,
bool? hasSubtitles,
bool? hasSpecialFeature,
bool? hasTrailer,
String? adjacentTo,
int? parentIndexNumber,
bool? hasParentalRating,
bool? isHd,
bool? is4K,
List<LocationType>? locationTypes,
List<LocationType>? excludeLocationTypes,
bool? isMissing,
bool? isUnaired,
num? minCommunityRating,
num? minCriticRating,
DateTime? minPremiereDate,
DateTime? minDateLastSaved,
DateTime? minDateLastSavedForUser,
DateTime? maxPremiereDate,
bool? hasOverview,
bool? hasImdbId,
bool? hasTmdbId,
bool? hasTvdbId,
bool? isMovie,
bool? isSeries,
bool? isNews,
bool? isKids,
bool? isSports,
List<String>? excludeItemIds,
int? startIndex,
int? limit,
bool? recursive,
String? searchTerm,
List<SortOrder>? sortOrder,
String? parentId,
List<ItemFields>? fields,
List<BaseItemKind>? excludeItemTypes,
List<BaseItemKind>? includeItemTypes,
List<ItemFilter>? filters,
bool? isFavorite,
List<MediaType>? mediaTypes,
List<ImageType>? imageTypes,
List<ItemSortBy>? sortBy,
bool? isPlayed,
List<String>? genres,
List<String>? officialRatings,
List<String>? tags,
List<int>? years,
bool? enableUserData,
int? imageTypeLimit,
List<ImageType>? enableImageTypes,
String? person,
List<String>? personIds,
List<String>? personTypes,
List<String>? studios,
List<String>? artists,
List<String>? excludeArtistIds,
List<String>? artistIds,
List<String>? albumArtistIds,
List<String>? contributingArtistIds,
List<String>? albums,
List<String>? albumIds,
List<String>? ids,
List<VideoType>? videoTypes,
String? minOfficialRating,
bool? isLocked,
bool? isPlaceHolder,
bool? hasOfficialRating,
bool? collapseBoxSetItems,
int? minWidth,
int? minHeight,
int? maxWidth,
int? maxHeight,
bool? is3D,
List<SeriesStatus>? seriesStatus,
String? nameStartsWithOrGreater,
String? nameStartsWith,
String? nameLessThan,
List<String>? studioIds,
List<String>? genreIds,
bool? enableTotalRecordCount,
bool? enableImages,
}) async {
final response = await api.itemsGet(
userId: account?.id,
maxOfficialRating: maxOfficialRating,
hasThemeSong: hasThemeSong,
hasThemeVideo: hasThemeVideo,
hasSubtitles: hasSubtitles,
hasSpecialFeature: hasSpecialFeature,
hasTrailer: hasTrailer,
adjacentTo: adjacentTo,
parentIndexNumber: parentIndexNumber,
hasParentalRating: hasParentalRating,
isHd: isHd,
is4K: is4K,
locationTypes: locationTypes,
excludeLocationTypes: excludeLocationTypes,
isMissing: isMissing,
isUnaired: isUnaired,
minCommunityRating: minCommunityRating,
minCriticRating: minCriticRating,
minPremiereDate: minPremiereDate,
minDateLastSaved: minDateLastSaved,
minDateLastSavedForUser: minDateLastSavedForUser,
maxPremiereDate: maxPremiereDate,
hasOverview: hasOverview,
hasImdbId: hasImdbId,
hasTmdbId: hasTmdbId,
hasTvdbId: hasTvdbId,
isMovie: isMovie,
isSeries: isSeries,
isNews: isNews,
isKids: isKids,
isSports: isSports,
excludeItemIds: excludeItemIds,
startIndex: startIndex,
limit: limit,
recursive: recursive,
searchTerm: searchTerm,
sortOrder: sortOrder,
sortBy: sortBy,
parentId: parentId,
fields: {...?fields, ItemFields.candelete, ItemFields.candownload}.toList(),
excludeItemTypes: excludeItemTypes,
includeItemTypes: includeItemTypes,
filters: filters,
isFavorite: isFavorite,
mediaTypes: mediaTypes,
imageTypes: imageTypes,
isPlayed: isPlayed,
genres: genres,
officialRatings: officialRatings,
tags: tags,
years: years,
enableUserData: enableUserData,
imageTypeLimit: imageTypeLimit,
enableImageTypes: enableImageTypes,
person: person,
personIds: personIds,
personTypes: personTypes,
studios: studios,
artists: artists,
excludeArtistIds: excludeArtistIds,
artistIds: artistIds,
albumArtistIds: albumArtistIds,
contributingArtistIds: contributingArtistIds,
albums: albums,
albumIds: albumIds,
ids: ids,
videoTypes: videoTypes,
minOfficialRating: minOfficialRating,
isLocked: isLocked,
isPlaceHolder: isPlaceHolder,
hasOfficialRating: hasOfficialRating,
collapseBoxSetItems: collapseBoxSetItems,
minWidth: minWidth,
minHeight: minHeight,
maxWidth: maxWidth,
maxHeight: maxHeight,
is3D: is3D,
seriesStatus: seriesStatus,
nameStartsWithOrGreater: nameStartsWithOrGreater,
nameStartsWith: nameStartsWith,
nameLessThan: nameLessThan,
studioIds: studioIds,
genreIds: genreIds,
enableTotalRecordCount: enableTotalRecordCount,
enableImages: enableImages,
);
return response.copyWith(
body: ServerQueryResult.fromBaseQuery(response.bodyOrThrow, ref),
);
}
Future<Response<List<ItemBaseModel>>> personsGet({
String? searchTerm,
int? limit,
bool? isFavorite,
}) async {
final response = await api.personsGet(
userId: account?.id,
limit: limit,
isFavorite: isFavorite,
);
return response.copyWith(
body: response.body?.items
?.map(
(e) => ItemBaseModel.fromBaseDto(e, ref),
)
.toList(),
);
}
Future<Response<List<ImageInfo>>> itemsItemIdImagesGet({
String? itemId,
bool? isFavorite,
}) async {
final response = await api.itemsItemIdImagesGet(itemId: itemId);
return response;
}
Future<Response<MetadataEditorInfo>> itemsItemIdMetadataEditorGet({
String? itemId,
}) async {
return api.itemsItemIdMetadataEditorGet(itemId: itemId);
}
Future<Response<RemoteImageResult>> itemsItemIdRemoteImagesGet({
String? itemId,
ImageType? type,
bool? includeAllLanguages,
}) async {
return api.itemsItemIdRemoteImagesGet(
itemId: itemId,
type: ItemsItemIdRemoteImagesGetType.values.firstWhereOrNull(
(element) => element.value == type?.value,
),
includeAllLanguages: includeAllLanguages,
);
}
Future<Response> itemsItemIdPost({
String? itemId,
required BaseItemDto? body,
}) async {
return api.itemsItemIdPost(
itemId: itemId,
body: body,
);
}
Future<Response<dynamic>?> itemIdImagesImageTypePost(
ImageType type,
String itemId,
Uint8List data,
) async {
return api.itemIdImagesImageTypePost(
type,
itemId,
data,
);
}
Future<Response> itemsItemIdRemoteImagesDownloadPost({
required String? itemId,
required ImageType? type,
String? imageUrl,
}) async {
return api.itemsItemIdRemoteImagesDownloadPost(
itemId: itemId,
type: ItemsItemIdRemoteImagesDownloadPostType.values.firstWhereOrNull(
(element) => element.value == type?.value,
),
imageUrl: imageUrl,
);
}
Future<Response> itemsItemIdImagesImageTypeDelete({
required String? itemId,
required ImageType? imageType,
int? imageIndex,
}) async {
return api.itemsItemIdImagesImageTypeDelete(
itemId: itemId,
imageType: ItemsItemIdImagesImageTypeDeleteImageType.values.firstWhereOrNull(
(element) => element.value == imageType?.value,
),
imageIndex: imageIndex,
);
}
Future<Response<BaseItemDtoQueryResult>> usersUserIdItemsResumeGet({
int? startIndex,
int? limit,
String? searchTerm,
String? parentId,
List<ItemFields>? fields,
List<MediaType>? mediaTypes,
bool? enableUserData,
bool? enableTotalRecordCount,
List<ImageType>? enableImageTypes,
List<BaseItemKind>? excludeItemTypes,
List<BaseItemKind>? includeItemTypes,
}) async {
return api.userItemsResumeGet(
userId: account?.id,
searchTerm: searchTerm,
parentId: parentId,
limit: limit,
fields: fields,
mediaTypes: mediaTypes,
enableTotalRecordCount: enableTotalRecordCount,
enableImageTypes: enableImageTypes,
enableUserData: enableUserData,
includeItemTypes: includeItemTypes,
excludeItemTypes: excludeItemTypes,
);
}
Future<Response<List<BaseItemDto>>> usersUserIdItemsLatestGet({
String? parentId,
List<ItemFields>? fields,
List<BaseItemKind>? includeItemTypes,
bool? isPlayed,
bool? enableImages,
int? imageTypeLimit,
List<ImageType>? enableImageTypes,
bool? enableUserData,
int? limit,
bool? groupItems,
}) async {
return api.itemsLatestGet(
parentId: parentId,
userId: account?.id,
fields: fields,
includeItemTypes: includeItemTypes,
isPlayed: isPlayed,
enableImages: enableImages,
imageTypeLimit: imageTypeLimit,
enableImageTypes: enableImageTypes,
enableUserData: enableUserData,
limit: limit,
groupItems: groupItems,
);
}
Future<Response<List<RecommendationDto>>> moviesRecommendationsGet({
String? parentId,
List<ItemFields>? fields,
int? categoryLimit,
int? itemLimit,
}) async {
return api.moviesRecommendationsGet(
userId: account?.id,
parentId: parentId,
fields: fields,
categoryLimit: categoryLimit,
itemLimit: itemLimit,
);
}
Future<Response<BaseItemDtoQueryResult>> showsNextUpGet({
int? startIndex,
int? limit,
String? parentId,
DateTime? nextUpDateCutoff,
List<ItemFields>? fields,
bool? enableUserData,
List<ImageType>? enableImageTypes,
int? imageTypeLimit,
}) async {
return api.showsNextUpGet(
userId: account?.id,
parentId: parentId,
limit: limit,
fields: fields,
enableResumable: false,
enableRewatching: false,
disableFirstEpisode: false,
nextUpDateCutoff: nextUpDateCutoff,
enableImageTypes: enableImageTypes,
enableUserData: enableUserData,
imageTypeLimit: imageTypeLimit,
);
}
Future<Response<BaseItemDtoQueryResult>> genresGet({
String? parentId,
List<ItemSortBy>? sortBy,
List<SortOrder>? sortOrder,
List<BaseItemKind>? includeItemTypes,
}) async {
return api.genresGet(
parentId: parentId,
userId: account?.id,
sortBy: sortBy,
sortOrder: sortOrder,
);
}
Future<Response> sessionsPlayingPost({required PlaybackStartInfo? body}) async => api.sessionsPlayingPost(body: body);
Future<Response> sessionsPlayingStoppedPost({
required PlaybackStopInfo? body,
Duration? totalDuration,
}) async {
final position = body?.positionTicks;
final totalTime = totalDuration?.toRuntimeTicks;
final maxTime = ref.read(userProvider.select((value) => value?.serverConfiguration?.maxResumePct ?? 90));
final response = await api.sessionsPlayingStoppedPost(
body: body?.copyWith(
failed: false,
),
);
//This is a temporary fix
if (totalTime != null && position != null && position > (totalTime * (maxTime / 100))) {
await usersUserIdPlayedItemsItemIdPost(itemId: body?.itemId, datePlayed: DateTime.now());
}
return response;
}
Future<Response> sessionsPlayingProgressPost({required PlaybackProgressInfo? body}) async =>
api.sessionsPlayingProgressPost(body: body);
Future<Response<PlaybackInfoResponse>> itemsItemIdPlaybackInfoPost({
required String? itemId,
int? maxStreamingBitrate,
int? startTimeTicks,
int? audioStreamIndex,
int? subtitleStreamIndex,
int? maxAudioChannels,
String? mediaSourceId,
String? liveStreamId,
bool? autoOpenLiveStream,
bool? enableDirectPlay,
bool? enableDirectStream,
bool? enableTranscoding,
bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy,
required PlaybackInfoDto? body,
}) async =>
api.itemsItemIdPlaybackInfoPost(
itemId: itemId,
userId: account?.id,
enableDirectPlay: enableDirectPlay,
enableDirectStream: enableDirectStream,
enableTranscoding: enableTranscoding,
autoOpenLiveStream: autoOpenLiveStream,
maxStreamingBitrate: maxStreamingBitrate,
liveStreamId: liveStreamId,
startTimeTicks: startTimeTicks,
mediaSourceId: mediaSourceId,
audioStreamIndex: audioStreamIndex,
subtitleStreamIndex: subtitleStreamIndex,
body: body,
);
Future<Response<BaseItemDtoQueryResult>> showsSeriesIdEpisodesGet({
required String? seriesId,
List<ItemFields>? fields,
int? season,
String? seasonId,
bool? isMissing,
String? adjacentTo,
String? startItemId,
int? startIndex,
int? limit,
bool? enableImages,
int? imageTypeLimit,
List<ImageType>? enableImageTypes,
bool? enableUserData,
ShowsSeriesIdEpisodesGetSortBy? sortBy,
}) async {
return api.showsSeriesIdEpisodesGet(
seriesId: seriesId,
userId: account?.id,
fields: fields,
isMissing: isMissing,
limit: limit,
sortBy: sortBy,
enableUserData: enableUserData,
startIndex: startIndex,
adjacentTo: adjacentTo,
startItemId: startItemId,
season: season,
seasonId: seasonId,
enableImages: enableImages,
enableImageTypes: enableImageTypes,
);
}
Future<List<ItemBaseModel>> fetchEpisodeFromShow({
required String? seriesId,
String? seasonId,
}) async {
final response = await showsSeriesIdEpisodesGet(seriesId: seriesId, seasonId: seasonId);
return response.body?.items?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList() ?? [];
}
Future<Response<BaseItemDtoQueryResult>> itemsItemIdSimilarGet({
String? itemId,
int? limit,
}) async {
return api.itemsItemIdSimilarGet(
userId: account?.id,
itemId: itemId,
limit: limit,
);
}
Future<Response<BaseItemDtoQueryResult>> usersUserIdItemsGet({
String? parentId,
bool? recursive,
List<BaseItemKind>? includeItemTypes,
}) async {
return api.itemsGet(
parentId: parentId,
userId: account?.id,
recursive: recursive,
includeItemTypes: includeItemTypes,
);
}
Future<Response<dynamic>> playlistsPlaylistIdItemsPost({
String? playlistId,
List<String>? ids,
}) async {
return api.playlistsPlaylistIdItemsPost(
playlistId: playlistId,
ids: ids,
userId: account?.id,
);
}
Future<Response<dynamic>> playlistsPost({
String? name,
List<String>? ids,
required CreatePlaylistDto? body,
}) async {
return api.playlistsPost(
name: name,
ids: ids,
userId: account?.id,
body: body,
);
}
Future<Response<List<AccountModel>>> usersPublicGet(
CredentialsModel credentials,
) async {
final response = await api.usersPublicGet();
return response.copyWith(
body: response.body?.map(
(e) {
var imageUrl = ref.read(imageUtilityProvider).getUserImageUrl(e.id ?? "");
return AccountModel(
name: e.name ?? "",
credentials: credentials,
id: e.id ?? "",
avatar: imageUrl,
lastUsed: DateTime.now(),
);
},
).toList(),
);
}
Future<Response<AuthenticationResult>> usersAuthenticateByNamePost({
required String userName,
required String password,
}) async {
return api.usersAuthenticateByNamePost(body: AuthenticateUserByName(username: userName, pw: password));
}
Future<Response<ServerConfiguration>> systemConfigurationGet() => api.systemConfigurationGet();
Future<Response<PublicSystemInfo>> systemInfoPublicGet() => api.systemInfoPublicGet();
Future<Response> sessionsLogoutPost() => api.sessionsLogoutPost();
Future<Response<String>> itemsItemIdDownloadGet({
String? itemId,
}) =>
api.itemsItemIdDownloadGet(itemId: itemId);
Future<Response> collectionsCollectionIdItemsPost({required String? collectionId, required List<String>? ids}) =>
api.collectionsCollectionIdItemsPost(collectionId: collectionId, ids: ids);
Future<Response> collectionsCollectionIdItemsDelete({required String? collectionId, required List<String>? ids}) =>
api.collectionsCollectionIdItemsDelete(collectionId: collectionId, ids: ids);
Future<Response> collectionsPost({String? name, List<String>? ids, String? parentId, bool? isLocked}) =>
api.collectionsPost(name: name, ids: ids, parentId: parentId, isLocked: isLocked);
Future<Response<BaseItemDtoQueryResult>> usersUserIdViewsGet({
bool? includeExternalContent,
List<CollectionType>? presetViews,
bool? includeHidden,
}) =>
api.userViewsGet(
userId: account?.id,
includeExternalContent: includeExternalContent,
presetViews: presetViews,
includeHidden: includeHidden);
Future<Response<List<ExternalIdInfo>>> itemsItemIdExternalIdInfosGet({required String? itemId}) =>
api.itemsItemIdExternalIdInfosGet(itemId: itemId);
Future<Response<List<RemoteSearchResult>>> itemsRemoteSearchSeriesPost(
{required SeriesInfoRemoteSearchQuery? body}) =>
api.itemsRemoteSearchSeriesPost(body: body);
Future<Response<List<RemoteSearchResult>>> itemsRemoteSearchMoviePost({required MovieInfoRemoteSearchQuery? body}) =>
api.itemsRemoteSearchMoviePost(body: body);
Future<Response<dynamic>> itemsRemoteSearchApplyItemIdPost({
required String? itemId,
bool? replaceAllImages,
required RemoteSearchResult? body,
}) =>
api.itemsRemoteSearchApplyItemIdPost(
itemId: itemId,
replaceAllImages: replaceAllImages,
body: body,
);
Future<Response<BaseItemDtoQueryResult>> showsSeriesIdSeasonsGet({
required String? seriesId,
bool? enableUserData,
bool? isMissing,
List<ItemFields>? fields,
}) =>
api.showsSeriesIdSeasonsGet(
seriesId: seriesId,
isMissing: isMissing,
enableUserData: enableUserData,
fields: fields,
);
Future<Response<QueryFilters>> itemsFilters2Get({
String? parentId,
List<BaseItemKind>? includeItemTypes,
bool? isAiring,
bool? isMovie,
bool? isSports,
bool? isKids,
bool? isNews,
bool? isSeries,
bool? recursive,
}) =>
api.itemsFilters2Get(
parentId: parentId,
includeItemTypes: includeItemTypes,
isAiring: isAiring,
isMovie: isMovie,
isSports: isSports,
isKids: isKids,
isNews: isNews,
isSeries: isSeries,
recursive: recursive,
);
Future<Response<BaseItemDtoQueryResult>> studiosGet({
int? startIndex,
int? limit,
String? searchTerm,
String? parentId,
List<ItemFields>? fields,
List<BaseItemKind>? excludeItemTypes,
List<BaseItemKind>? includeItemTypes,
bool? isFavorite,
bool? enableUserData,
int? imageTypeLimit,
List<ImageType>? enableImageTypes,
String? userId,
String? nameStartsWithOrGreater,
String? nameStartsWith,
String? nameLessThan,
bool? enableImages,
bool? enableTotalRecordCount,
}) =>
api.studiosGet(
startIndex: startIndex,
limit: limit,
searchTerm: searchTerm,
parentId: parentId,
fields: fields,
excludeItemTypes: excludeItemTypes,
includeItemTypes: includeItemTypes,
isFavorite: isFavorite,
enableUserData: enableUserData,
imageTypeLimit: imageTypeLimit,
enableImageTypes: enableImageTypes,
nameStartsWithOrGreater: nameStartsWithOrGreater,
nameStartsWith: nameStartsWith,
nameLessThan: nameLessThan,
enableImages: enableImages,
enableTotalRecordCount: enableTotalRecordCount,
);
Future<Response<ServerQueryResult>> playlistsPlaylistIdItemsGet({
required String? playlistId,
int? startIndex,
int? limit,
List<ItemFields>? fields,
bool? enableImages,
bool? enableUserData,
int? imageTypeLimit,
List<ImageType>? enableImageTypes,
}) async {
final response = await api.playlistsPlaylistIdItemsGet(
playlistId: playlistId,
userId: account?.id,
startIndex: startIndex,
limit: limit,
fields: fields,
enableImages: enableImages,
enableUserData: enableUserData,
imageTypeLimit: imageTypeLimit,
enableImageTypes: enableImageTypes,
);
return response.copyWith(
body: ServerQueryResult.fromBaseQuery(response.bodyOrThrow, ref),
);
}
Future<Response> playlistsPlaylistIdItemsDelete({required String? playlistId, List<String>? entryIds}) =>
api.playlistsPlaylistIdItemsDelete(
playlistId: playlistId,
entryIds: entryIds,
);
Future<Response<UserDto>> usersMeGet() => api.usersMeGet();
Future<Response> configuration() => api.systemConfigurationGet();
Future<Response> itemsItemIdRefreshPost({
required String? itemId,
MetadataRefresh? metadataRefreshMode,
MetadataRefresh? imageRefreshMode,
bool? replaceAllMetadata,
bool? replaceAllImages,
}) =>
api.itemsItemIdRefreshPost(
itemId: itemId,
metadataRefreshMode: metadataRefreshMode?.metadataRefreshMode,
imageRefreshMode: imageRefreshMode?.imageRefreshMode,
replaceAllMetadata: replaceAllMetadata,
replaceAllImages: replaceAllImages,
);
Future<Response<UserItemDataDto>> usersUserIdFavoriteItemsItemIdPost({
required String? itemId,
}) =>
api.userFavoriteItemsItemIdPost(
itemId: itemId,
userId: account?.id,
);
Future<Response<UserItemDataDto>> usersUserIdFavoriteItemsItemIdDelete({
required String? itemId,
}) =>
api.userFavoriteItemsItemIdDelete(
itemId: itemId,
userId: account?.id,
);
Future<Response<UserItemDataDto>> usersUserIdPlayedItemsItemIdPost({
required String? itemId,
DateTime? datePlayed,
}) =>
api.userPlayedItemsItemIdPost(itemId: itemId, userId: account?.id, datePlayed: datePlayed);
Future<Response<UserItemDataDto>> usersUserIdPlayedItemsItemIdDelete({
required String? itemId,
}) =>
api.userPlayedItemsItemIdDelete(
itemId: itemId,
userId: account?.id,
);
Future<Response<IntroOutSkipModel>?> introSkipGet({
required String id,
}) async {
try {
final response = await api.episodeIdIntroTimestampsGet(id: id);
final outro = await api.episodeIdIntroSkipperSegmentsGet(id: id);
final map = jsonDecode(outro.bodyString) as Map<String, dynamic>;
final newModel = IntroOutSkipModel(
intro:
map["Introduction"] != null ? IntroSkipModel.fromJson(map["Introduction"] as Map<String, dynamic>) : null,
credits: map["Credits"] != null ? IntroSkipModel.fromJson(map["Credits"] as Map<String, dynamic>) : null,
);
return response.copyWith(
body: newModel,
);
} catch (e) {
log(e.toString());
return null;
}
}
Future<Response<TrickPlayModel>?> getTrickPlay({
required ItemBaseModel? item,
int? width,
required Ref ref,
}) async {
try {
if (item == null) return null;
if (item.overview.trickPlayInfo?.isEmpty == true) {
return null;
}
final trickPlayModel = item.overview.trickPlayInfo?.values.lastOrNull;
if (trickPlayModel == null) return null;
final response = await api.videosItemIdTrickplayWidthTilesM3u8Get(
itemId: item.id,
width: trickPlayModel.width,
);
final server = ref.read(userProvider)?.server;
if (server == null) return null;
final lines = response.bodyString.split('\n')
..removeWhere((element) => element.startsWith('#') || !element.contains('.jpg'));
return response.copyWith(
body: trickPlayModel.copyWith(
images: lines
.map(
(e) => joinAll([server, 'Videos/${item.id}/Trickplay/${trickPlayModel.width}', e]),
)
.toList()));
} catch (e) {
log(e.toString());
return null;
}
}
Future<Response<List<SessionInfo>>> sessionsInfo(String deviceId) async => api.sessionsGet(deviceId: deviceId);
Future<Response<bool>> quickConnect(String code) async => api.quickConnectAuthorizePost(code: code);
Future<Response<bool>> quickConnectEnabled() async => api.quickConnectEnabledGet();
Future<Response<dynamic>> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId);
}

View file

@ -0,0 +1,64 @@
import 'dart:async';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'session_info_provider.freezed.dart';
part 'session_info_provider.g.dart';
@Riverpod()
class SessionInfo extends _$SessionInfo {
late final api = ref.read(jellyApiProvider);
Timer? _timer;
@override
SessionInfoModel build() {
ref.onDispose(() => _timer?.cancel());
_startTimer();
return SessionInfoModel();
}
void _startTimer() {
_fetchData();
_timer = Timer.periodic(Duration(seconds: 2), (timer) async {
await _fetchData();
});
}
Future<void> _fetchData() async {
final deviceId = ref.read(userProvider)?.credentials.deviceId;
if (deviceId == null) {
state = SessionInfoModel();
return;
}
final response = await api.sessionsInfo(deviceId);
final session = response.body?.firstOrNull;
if (session == null) {
state = SessionInfoModel();
return;
}
state = SessionInfoModel(
playbackModel: session.playState?.playMethod?.name,
transCodeInfo: session.transcodingInfo,
);
}
}
@freezed
class SessionInfoModel with _$SessionInfoModel {
const SessionInfoModel._();
factory SessionInfoModel({
String? playbackModel,
TranscodingInfo? transCodeInfo,
}) = _SessionInfoModel;
factory SessionInfoModel.fromJson(Map<String, dynamic> json) => _$SessionInfoModelFromJson(json);
}

View file

@ -0,0 +1,173 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'session_info_provider.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SessionInfoModel _$SessionInfoModelFromJson(Map<String, dynamic> json) {
return _SessionInfoModel.fromJson(json);
}
/// @nodoc
mixin _$SessionInfoModel {
String? get playbackModel => throw _privateConstructorUsedError;
TranscodingInfo? get transCodeInfo => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$SessionInfoModelCopyWith<SessionInfoModel> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SessionInfoModelCopyWith<$Res> {
factory $SessionInfoModelCopyWith(
SessionInfoModel value, $Res Function(SessionInfoModel) then) =
_$SessionInfoModelCopyWithImpl<$Res, SessionInfoModel>;
@useResult
$Res call({String? playbackModel, TranscodingInfo? transCodeInfo});
}
/// @nodoc
class _$SessionInfoModelCopyWithImpl<$Res, $Val extends SessionInfoModel>
implements $SessionInfoModelCopyWith<$Res> {
_$SessionInfoModelCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? playbackModel = freezed,
Object? transCodeInfo = freezed,
}) {
return _then(_value.copyWith(
playbackModel: freezed == playbackModel
? _value.playbackModel
: playbackModel // ignore: cast_nullable_to_non_nullable
as String?,
transCodeInfo: freezed == transCodeInfo
? _value.transCodeInfo
: transCodeInfo // ignore: cast_nullable_to_non_nullable
as TranscodingInfo?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SessionInfoModelImplCopyWith<$Res>
implements $SessionInfoModelCopyWith<$Res> {
factory _$$SessionInfoModelImplCopyWith(_$SessionInfoModelImpl value,
$Res Function(_$SessionInfoModelImpl) then) =
__$$SessionInfoModelImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({String? playbackModel, TranscodingInfo? transCodeInfo});
}
/// @nodoc
class __$$SessionInfoModelImplCopyWithImpl<$Res>
extends _$SessionInfoModelCopyWithImpl<$Res, _$SessionInfoModelImpl>
implements _$$SessionInfoModelImplCopyWith<$Res> {
__$$SessionInfoModelImplCopyWithImpl(_$SessionInfoModelImpl _value,
$Res Function(_$SessionInfoModelImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? playbackModel = freezed,
Object? transCodeInfo = freezed,
}) {
return _then(_$SessionInfoModelImpl(
playbackModel: freezed == playbackModel
? _value.playbackModel
: playbackModel // ignore: cast_nullable_to_non_nullable
as String?,
transCodeInfo: freezed == transCodeInfo
? _value.transCodeInfo
: transCodeInfo // ignore: cast_nullable_to_non_nullable
as TranscodingInfo?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SessionInfoModelImpl extends _SessionInfoModel {
_$SessionInfoModelImpl({this.playbackModel, this.transCodeInfo}) : super._();
factory _$SessionInfoModelImpl.fromJson(Map<String, dynamic> json) =>
_$$SessionInfoModelImplFromJson(json);
@override
final String? playbackModel;
@override
final TranscodingInfo? transCodeInfo;
@override
String toString() {
return 'SessionInfoModel(playbackModel: $playbackModel, transCodeInfo: $transCodeInfo)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SessionInfoModelImpl &&
(identical(other.playbackModel, playbackModel) ||
other.playbackModel == playbackModel) &&
(identical(other.transCodeInfo, transCodeInfo) ||
other.transCodeInfo == transCodeInfo));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(runtimeType, playbackModel, transCodeInfo);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$SessionInfoModelImplCopyWith<_$SessionInfoModelImpl> get copyWith =>
__$$SessionInfoModelImplCopyWithImpl<_$SessionInfoModelImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SessionInfoModelImplToJson(
this,
);
}
}
abstract class _SessionInfoModel extends SessionInfoModel {
factory _SessionInfoModel(
{final String? playbackModel,
final TranscodingInfo? transCodeInfo}) = _$SessionInfoModelImpl;
_SessionInfoModel._() : super._();
factory _SessionInfoModel.fromJson(Map<String, dynamic> json) =
_$SessionInfoModelImpl.fromJson;
@override
String? get playbackModel;
@override
TranscodingInfo? get transCodeInfo;
@override
@JsonKey(ignore: true)
_$$SessionInfoModelImplCopyWith<_$SessionInfoModelImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -0,0 +1,46 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'session_info_provider.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SessionInfoModelImpl _$$SessionInfoModelImplFromJson(
Map<String, dynamic> json) =>
_$SessionInfoModelImpl(
playbackModel: json['playbackModel'] as String?,
transCodeInfo: json['transCodeInfo'] == null
? null
: TranscodingInfo.fromJson(
json['transCodeInfo'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SessionInfoModelImplToJson(
_$SessionInfoModelImpl instance) =>
<String, dynamic>{
'playbackModel': instance.playbackModel,
'transCodeInfo': instance.transCodeInfo,
};
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sessionInfoHash() => r'ab5afcada1c9677cadda954c9abf7eb361dc057d';
/// See also [SessionInfo].
@ProviderFor(SessionInfo)
final sessionInfoProvider =
AutoDisposeNotifierProvider<SessionInfo, SessionInfoModel>.internal(
SessionInfo.new,
name: r'sessionInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$sessionInfoHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SessionInfo = AutoDisposeNotifier<SessionInfoModel>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,140 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/util/debouncer.dart';
enum ReadDirection {
leftToRight,
rightToLeft;
String toMap() => name;
static ReadDirection fromMap(String? map) {
return ReadDirection.values.firstWhereOrNull((element) => element.name == map) ?? ReadDirection.leftToRight;
}
}
enum InitZoomState {
contained,
covered;
String toMap() => name;
static InitZoomState fromMap(String? map) {
return InitZoomState.values.firstWhereOrNull((element) => element.name == map) ?? InitZoomState.contained;
}
}
enum GestureCache {
contained,
covered;
String toMap() => name;
static InitZoomState fromMap(String? map) {
return InitZoomState.values.firstWhereOrNull((element) => element.name == map) ?? InitZoomState.contained;
}
}
class BookViewerSettingsModel {
final double? screenBrightness;
final ReadDirection readDirection;
final InitZoomState initZoomState;
final bool cachePageZoom;
final bool keepPageZoom;
final bool disableScrollOnZoom;
BookViewerSettingsModel({
this.screenBrightness,
this.readDirection = ReadDirection.leftToRight,
this.initZoomState = InitZoomState.contained,
this.cachePageZoom = false,
this.keepPageZoom = true,
this.disableScrollOnZoom = false,
});
BookViewerSettingsModel copyWith({
ValueGetter<double?>? screenBrightness,
ReadDirection? readDirection,
InitZoomState? initZoomState,
bool? cachePageZoom,
bool? keepPageZoom,
bool? disableScrollOnZoom,
}) {
return BookViewerSettingsModel(
screenBrightness: screenBrightness != null ? screenBrightness.call() : this.screenBrightness,
readDirection: readDirection ?? this.readDirection,
initZoomState: initZoomState ?? this.initZoomState,
cachePageZoom: cachePageZoom ?? this.cachePageZoom,
keepPageZoom: keepPageZoom ?? this.keepPageZoom,
disableScrollOnZoom: disableScrollOnZoom ?? this.disableScrollOnZoom,
);
}
Map<String, dynamic> toMap() {
return {
'screenBrightness': screenBrightness,
'readDirection': readDirection.toMap(),
'initZoomState': initZoomState.toMap(),
'cachePageZoom': cachePageZoom,
'keepPageZoom': keepPageZoom,
'disableScrollOnZoom': disableScrollOnZoom,
};
}
factory BookViewerSettingsModel.fromMap(Map<String, dynamic> map) {
return BookViewerSettingsModel(
screenBrightness: map['screenBrightness']?.toDouble(),
readDirection: ReadDirection.fromMap(map['readDirection']),
initZoomState: InitZoomState.fromMap(map['initZoomState']),
cachePageZoom: map['cachePageZoom'] ?? false,
keepPageZoom: map['keepPageZoom'] ?? true,
disableScrollOnZoom: map['disableScrollOnZoom'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory BookViewerSettingsModel.fromJson(String source) => BookViewerSettingsModel.fromMap(json.decode(source));
}
final bookViewerSettingsProvider = StateNotifierProvider<BookViewerSettingsNotifier, BookViewerSettingsModel>((ref) {
return BookViewerSettingsNotifier(ref);
});
class BookViewerSettingsNotifier extends StateNotifier<BookViewerSettingsModel> {
BookViewerSettingsNotifier(this.ref) : super(BookViewerSettingsModel());
final Ref ref;
final Debouncer _debouncer = Debouncer(const Duration(seconds: 1));
@override
set state(BookViewerSettingsModel value) {
_debouncer.run(() => ref.read(sharedUtilityProvider).bookViewSettings = value);
super.state = value;
}
void setScreenBrightness(double? value) async {
state = state.copyWith(
screenBrightness: () => value,
);
if (state.screenBrightness != null) {
ScreenBrightness().setScreenBrightness(state.screenBrightness!);
} else {
ScreenBrightness().resetScreenBrightness();
}
}
setSavedBrightness() {
if (state.screenBrightness != null) {
ScreenBrightness().setScreenBrightness(state.screenBrightness!);
}
}
void update(BookViewerSettingsModel Function(BookViewerSettingsModel state) outgoing) => state = outgoing(state);
}

View file

@ -0,0 +1,54 @@
import 'package:fladder/models/settings/client_settings_model.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/util/custom_color_themes.dart';
import 'package:fladder/util/debouncer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final clientSettingsProvider = StateNotifierProvider<ClientSettingsNotifier, ClientSettingsModel>((ref) {
return ClientSettingsNotifier(ref);
});
class ClientSettingsNotifier extends StateNotifier<ClientSettingsModel> {
ClientSettingsNotifier(this.ref) : super(ClientSettingsModel());
final Ref ref;
final Debouncer _debouncer = Debouncer(const Duration(seconds: 1));
@override
set state(ClientSettingsModel value) {
super.state = value;
_debouncer.run(() => ref.read(sharedUtilityProvider).clientSettings = state);
}
void setWindowPosition(Offset windowPosition) =>
state = state.copyWith(position: Vector2.fromPosition(windowPosition));
void setWindowSize(Size windowSize) => state = state.copyWith(size: Vector2.fromSize(windowSize));
void setThemeMode(ThemeMode? themeMode) {
if (themeMode == null) return;
state = state.copyWith(themeMode: themeMode);
}
void setThemeColor(ColorThemes? themeColor) => state = state.copyWith(themeColor: themeColor);
void setAmoledBlack(bool? value) => state = state.copyWith(amoledBlack: value ?? false);
void setBlurPlaceholders(bool value) => state = state.copyWith(blurPlaceHolders: value);
void setTimeOut(Duration? duration) => state = state.copyWith(timeOut: duration);
void setBlurEpisodes(bool value) => state = state.copyWith(blurUpcomingEpisodes: value);
void setMediaKeys(bool value) => state = state.copyWith(enableMediaKeys: value);
void setPosterSize(double value) => state = state.copyWith(posterSize: value.clamp(0.5, 1.5));
void addPosterSize(double value) => state = state.copyWith(posterSize: (state.posterSize + value).clamp(0.5, 1.5));
void setSyncPath(String? path) => state = state.copyWith(syncPath: path);
void update(Function(ClientSettingsModel current) value) => state = value(state);
}

View file

@ -0,0 +1,21 @@
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final homeSettingsProvider = StateNotifierProvider<HomeSettingsNotifier, HomeSettingsModel>((ref) {
return HomeSettingsNotifier(ref);
});
class HomeSettingsNotifier extends StateNotifier<HomeSettingsModel> {
HomeSettingsNotifier(this.ref) : super(HomeSettingsModel());
final Ref ref;
@override
set state(HomeSettingsModel value) {
super.state = value;
ref.read(sharedUtilityProvider).homeSettings = value;
}
update(HomeSettingsModel Function(HomeSettingsModel currentState) value) => state = value(state);
}

View file

@ -0,0 +1,82 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/shared_provider.dart';
class PhotoViewSettingsModel {
final bool repeat;
final bool mute;
final bool autoPlay;
final bool theaterMode;
final Duration timer;
PhotoViewSettingsModel({
this.repeat = true,
this.mute = false,
this.autoPlay = false,
this.theaterMode = false,
this.timer = const Duration(seconds: 15),
});
PhotoViewSettingsModel copyWith({
bool? repeat,
bool? mute,
bool? autoPlay,
bool? theaterMode,
Duration? timer,
}) {
return PhotoViewSettingsModel(
repeat: repeat ?? this.repeat,
mute: mute ?? this.mute,
autoPlay: autoPlay ?? this.autoPlay,
theaterMode: theaterMode ?? this.theaterMode,
timer: timer ?? this.timer,
);
}
Map<String, dynamic> toMap() {
return {
'repeat': repeat,
'mute': mute,
'autoPlay': autoPlay,
'theaterMode': theaterMode,
'timer': timer.inMilliseconds,
};
}
factory PhotoViewSettingsModel.fromMap(Map<String, dynamic> map) {
return PhotoViewSettingsModel(
repeat: map['repeat'] ?? false,
mute: map['mute'] ?? false,
autoPlay: map['autoPlay'] ?? false,
theaterMode: map['theaterMode'] ?? false,
timer: map['timer'] != null ? Duration(milliseconds: map['timer'] as int) : const Duration(seconds: 15),
);
}
String toJson() => json.encode(toMap());
factory PhotoViewSettingsModel.fromJson(String source) => PhotoViewSettingsModel.fromMap(json.decode(source));
}
final photoViewSettingsProvider = StateNotifierProvider<PhotoViewSettingsNotifier, PhotoViewSettingsModel>((ref) {
return PhotoViewSettingsNotifier(ref);
});
final testProviderProvider = StateProvider<int>((ref) {
return 0;
});
class PhotoViewSettingsNotifier extends StateNotifier<PhotoViewSettingsModel> {
PhotoViewSettingsNotifier(this.ref) : super(PhotoViewSettingsModel());
final Ref ref;
@override
set state(PhotoViewSettingsModel value) {
super.state = value;
ref.read(sharedUtilityProvider).photoViewSettings = value;
}
PhotoViewSettingsModel update(PhotoViewSettingsModel Function(PhotoViewSettingsModel state) cb) => state = cb(state);
}

View file

@ -0,0 +1,40 @@
import 'dart:ui';
import 'package:fladder/models/settings/subtitle_settings_model.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final subtitleSettingsProvider = StateNotifierProvider<SubtitleSettingsNotifier, SubtitleSettingsModel>((ref) {
return SubtitleSettingsNotifier(ref);
});
class SubtitleSettingsNotifier extends StateNotifier<SubtitleSettingsModel> {
SubtitleSettingsNotifier(this.ref) : super(const SubtitleSettingsModel());
final Ref ref;
@override
set state(SubtitleSettingsModel value) {
super.state = value;
ref.read(sharedUtilityProvider).subtitleSettings = value;
}
void setFontSize(double value) => state = state.copyWith(fontSize: value);
void setVerticalOffset(double value) => state = state.copyWith(verticalOffset: value);
void setSubColor(Color color) => state = state.copyWith(color: color);
void setOutlineColor(Color e) => state = state.copyWith(outlineColor: e);
setOutlineThickness(double value) => state = state.copyWith(outlineSize: value);
void resetSettings({SubtitleSettingsModel? value}) => state = value ?? const SubtitleSettingsModel();
void setFontWeight(FontWeight? value) => state = state.copyWith(fontWeight: value);
setBackGroundOpacity(double value) =>
state = state.copyWith(backGroundColor: state.backGroundColor.withOpacity(value));
setShadowIntensity(double value) => state = state.copyWith(shadow: value);
}

View file

@ -0,0 +1,57 @@
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_brightness/screen_brightness.dart';
final videoPlayerSettingsProvider =
StateNotifierProvider<VideoPlayerSettingsProviderNotifier, VideoPlayerSettingsModel>((ref) {
return VideoPlayerSettingsProviderNotifier(ref);
});
class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSettingsModel> {
VideoPlayerSettingsProviderNotifier(this.ref) : super(const VideoPlayerSettingsModel());
final Ref ref;
@override
set state(VideoPlayerSettingsModel value) {
final oldState = super.state;
super.state = value;
ref.read(sharedUtilityProvider).videoPlayerSettings = value;
if (!oldState.playerSame(value)) {
ref.read(videoPlayerProvider.notifier).init();
}
}
void setScreenBrightness(double? value) async {
state = state.copyWith(
screenBrightness: () => value,
);
if (state.screenBrightness != null) {
ScreenBrightness().setScreenBrightness(state.screenBrightness!);
} else {
ScreenBrightness().resetScreenBrightness();
}
}
setSavedBrightness() {
if (state.screenBrightness != null) {
ScreenBrightness().setScreenBrightness(state.screenBrightness!);
}
}
void setFillScreen(bool? value, {BuildContext? context}) {
state = state.copyWith(fillScreen: value);
}
void setHardwareAccel(bool? value) => state = state.copyWith(hardwareAccel: value);
void setUseLibass(bool? value) => state = state.copyWith(useLibass: value);
void setFitType(BoxFit? value) => state = state.copyWith(videoFit: value);
void setVolume(double value) => state = state.copyWith(internalVolume: value);
void steppedVolume(int i) => state = state.copyWith(internalVolume: (state.volume + i).clamp(0, 100));
}

View file

@ -0,0 +1,202 @@
import 'dart:convert';
import 'dart:developer';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/settings/client_settings_model.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/models/settings/subtitle_settings_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/home_settings_provider.dart';
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError();
});
final sharedUtilityProvider = Provider<SharedUtility>((ref) {
final sharedPrefs = ref.watch(sharedPreferencesProvider);
return SharedUtility(ref: ref, sharedPreferences: sharedPrefs);
});
class SharedUtility {
SharedUtility({
required this.ref,
required this.sharedPreferences,
});
final Ref ref;
final SharedPreferences sharedPreferences;
late final JellyService api = ref.read(jellyApiProvider);
Future<bool?> loadSettings() async {
try {
ref.read(clientSettingsProvider.notifier).state = clientSettings;
ref.read(homeSettingsProvider.notifier).state = homeSettings;
ref.read(videoPlayerSettingsProvider.notifier).state = videoPlayerSettings;
ref.read(subtitleSettingsProvider.notifier).state = subtitleSettings;
ref.read(bookViewerSettingsProvider.notifier).state = bookViewSettings;
ref.read(photoViewSettingsProvider.notifier).state = photoViewSettings;
return true;
} catch (e) {
return false;
}
}
Future<bool?> addAccount(AccountModel account) async {
return await saveAccounts(getAccounts()
..add(account.copyWith(
lastUsed: DateTime.now(),
)));
}
Future<bool?> removeAccount(AccountModel? account) async {
if (account == null) return null;
//Try to logout user
await ref.read(userProvider.notifier).forceLogoutUser(account);
//Remove from local database
final savedAccounts = getAccounts();
savedAccounts.removeWhere((element) {
return element.sameIdentity(account);
});
return (await saveAccounts(savedAccounts));
}
List<AccountModel> getAccounts() {
final savedAccounts = sharedPreferences.getStringList(_loginCredentialsKey);
try {
return savedAccounts != null ? savedAccounts.map((e) => AccountModel.fromJson(jsonDecode(e))).toList() : [];
} catch (_, stacktrace) {
log(stacktrace.toString());
return [];
}
}
AccountModel? getActiveAccount() {
try {
final accounts = getAccounts();
AccountModel recentUsedAccount = accounts.reduce((lastLoggedIn, element) {
return (element.lastUsed.compareTo(lastLoggedIn.lastUsed)) > 0 ? element : lastLoggedIn;
});
if (recentUsedAccount.authMethod == Authentication.autoLogin) return recentUsedAccount;
return null;
} catch (e) {
log(e.toString());
return null;
}
}
Future<bool?> saveAccounts(List<AccountModel> accounts) async =>
sharedPreferences.setStringList(_loginCredentialsKey, accounts.map((e) => jsonEncode(e)).toList());
ClientSettingsModel get clientSettings {
try {
return ClientSettingsModel.fromJson(jsonDecode(sharedPreferences.getString(_clientSettingsKey) ?? ""));
} catch (e) {
log(e.toString());
return ClientSettingsModel();
}
}
set clientSettings(ClientSettingsModel settings) =>
sharedPreferences.setString(_clientSettingsKey, jsonEncode(settings.toJson()));
HomeSettingsModel get homeSettings {
try {
return HomeSettingsModel.fromJson(sharedPreferences.getString(_homeSettingsKey) ?? "");
} catch (e) {
log(e.toString());
return HomeSettingsModel();
}
}
set homeSettings(HomeSettingsModel settings) => sharedPreferences.setString(_homeSettingsKey, settings.toJson());
BookViewerSettingsModel get bookViewSettings {
try {
return BookViewerSettingsModel.fromJson(sharedPreferences.getString(_bookViewSettingsKey) ?? "");
} catch (e) {
log(e.toString());
return BookViewerSettingsModel();
}
}
set bookViewSettings(BookViewerSettingsModel settings) {
sharedPreferences.setString(_bookViewSettingsKey, settings.toJson());
}
Future<void> updateAccountInfo(AccountModel account) async {
final accounts = getAccounts();
await Future.microtask(() async {
await saveAccounts(accounts.map((e) {
if (e.sameIdentity(account)) {
return account.copyWith(
lastUsed: DateTime.now(),
);
} else {
return e;
}
}).toList());
});
}
SubtitleSettingsModel get subtitleSettings {
try {
return SubtitleSettingsModel.fromJson(sharedPreferences.getString(_subtitleSettingsKey) ?? "");
} catch (e) {
log(e.toString());
return const SubtitleSettingsModel();
}
}
set subtitleSettings(SubtitleSettingsModel settings) {
sharedPreferences.setString(_subtitleSettingsKey, settings.toJson());
}
VideoPlayerSettingsModel get videoPlayerSettings {
try {
return VideoPlayerSettingsModel.fromJson(sharedPreferences.getString(_videoPlayerSettingsKey) ?? "");
} catch (e) {
log(e.toString());
return const VideoPlayerSettingsModel();
}
}
set videoPlayerSettings(VideoPlayerSettingsModel settings) {
sharedPreferences.setString(_videoPlayerSettingsKey, settings.toJson());
}
PhotoViewSettingsModel get photoViewSettings {
try {
return PhotoViewSettingsModel.fromJson(sharedPreferences.getString(_photoViewSettingsKey) ?? "");
} catch (e) {
log(e.toString());
return PhotoViewSettingsModel();
}
}
set photoViewSettings(PhotoViewSettingsModel settings) {
sharedPreferences.setString(_photoViewSettingsKey, settings.toJson());
}
}
const String _loginCredentialsKey = 'loginCredentialsKey';
const String _clientSettingsKey = 'clientSettings';
const String _homeSettingsKey = 'homeSettings';
const String _videoPlayerSettingsKey = 'videoPlayerSettings';
const String _subtitleSettingsKey = 'subtitleSettings';
const String _bookViewSettingsKey = 'bookViewSettings';
const String _photoViewSettingsKey = 'photoViewSettings';

View file

@ -0,0 +1,16 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'background_download_provider.g.dart';
@Riverpod(keepAlive: true)
FileDownloader backgroundDownloader(BackgroundDownloaderRef ref) {
return FileDownloader()
..trackTasks()
..configureNotification(
running: TaskNotification('Downloading', 'file: {filename}'),
complete: TaskNotification('Download finished', 'file: {filename}'),
paused: TaskNotification('Download paused', 'file: {filename}'),
progressBar: true,
);
}

View file

@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'background_download_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$backgroundDownloaderHash() =>
r'2bc7a06682cdcfa9a754dce9b7f7ea48f873682e';
/// See also [backgroundDownloader].
@ProviderFor(backgroundDownloader)
final backgroundDownloaderProvider = Provider<FileDownloader>.internal(
backgroundDownloader,
name: r'backgroundDownloaderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$backgroundDownloaderHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef BackgroundDownloaderRef = ProviderRef<FileDownloader>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,104 @@
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/models/syncing/download_stream.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'sync_provider_helpers.g.dart';
@riverpod
class SyncChildren extends _$SyncChildren {
@override
List<SyncedItem> build(SyncedItem arg) {
final syncedItemIsar = ref.watch(syncProvider.notifier).isar;
final allChildren = <SyncedItem>[];
List<SyncedItem> toProcess = [arg];
while (toProcess.isNotEmpty) {
final currentLevel = toProcess.map(
(parent) {
final children = syncedItemIsar?.iSyncedItems.where().parentIdEqualTo(parent.id).sortBySortKey().findAll();
return children?.map((e) => SyncedItem.fromIsar(e, ref.read(syncProvider.notifier).syncPath ?? "")) ??
<SyncedItem>[];
},
);
allChildren.addAll(currentLevel.expand((list) => list));
toProcess = currentLevel.expand((list) => list).toList();
}
return allChildren;
}
}
@riverpod
class SyncDownloadStatus extends _$SyncDownloadStatus {
@override
DownloadStream? build(SyncedItem arg) {
final nestedChildren = ref.watch(syncChildrenProvider(arg));
ref.watch(downloadTasksProvider(arg.id));
for (var element in nestedChildren) {
ref.watch(downloadTasksProvider(element.id));
}
DownloadStream mainStream = ref.read(downloadTasksProvider(arg.id));
int downloadCount = 0;
double fullProgress = mainStream.hasDownload ? mainStream.progress : 0.0;
for (var i = 0; i < nestedChildren.length; i++) {
final childItem = nestedChildren[i];
final downloadStream = ref.read(downloadTasksProvider(childItem.id));
if (downloadStream.hasDownload) {
downloadCount++;
fullProgress += downloadStream.progress;
mainStream = mainStream.copyWith(status: downloadStream.status);
}
}
return mainStream.copyWith(
progress: fullProgress / downloadCount.clamp(1, double.infinity).toInt(),
);
}
}
@riverpod
class SyncStatuses extends _$SyncStatuses {
@override
FutureOr<SyncStatus> build(SyncedItem arg) async {
final nestedChildren = ref.watch(syncChildrenProvider(arg));
ref.watch(downloadTasksProvider(arg.id));
for (var element in nestedChildren) {
ref.watch(downloadTasksProvider(element.id));
}
for (var i = 0; i < nestedChildren.length; i++) {
final item = nestedChildren[i];
if (item.hasVideoFile && !await item.videoFile.exists()) {
return SyncStatus.partially;
}
}
if (arg.hasVideoFile && !await arg.videoFile.exists()) {
return SyncStatus.partially;
}
return SyncStatus.complete;
}
}
@riverpod
class SyncSize extends _$SyncSize {
@override
int? build(SyncedItem arg) {
final nestedChildren = ref.watch(syncChildrenProvider(arg));
ref.watch(downloadTasksProvider(arg.id));
for (var element in nestedChildren) {
ref.watch(downloadTasksProvider(element.id));
}
int size = arg.fileSize ?? 0;
for (var element in nestedChildren) {
size += element.fileSize ?? 0;
}
return size;
}
}

View file

@ -0,0 +1,603 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync_provider_helpers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$syncChildrenHash() => r'798a9998103adae18a238a69219559f3ccbf3def';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$SyncChildren
extends BuildlessAutoDisposeNotifier<List<SyncedItem>> {
late final SyncedItem arg;
List<SyncedItem> build(
SyncedItem arg,
);
}
/// See also [SyncChildren].
@ProviderFor(SyncChildren)
const syncChildrenProvider = SyncChildrenFamily();
/// See also [SyncChildren].
class SyncChildrenFamily extends Family<List<SyncedItem>> {
/// See also [SyncChildren].
const SyncChildrenFamily();
/// See also [SyncChildren].
SyncChildrenProvider call(
SyncedItem arg,
) {
return SyncChildrenProvider(
arg,
);
}
@override
SyncChildrenProvider getProviderOverride(
covariant SyncChildrenProvider provider,
) {
return call(
provider.arg,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'syncChildrenProvider';
}
/// See also [SyncChildren].
class SyncChildrenProvider
extends AutoDisposeNotifierProviderImpl<SyncChildren, List<SyncedItem>> {
/// See also [SyncChildren].
SyncChildrenProvider(
SyncedItem arg,
) : this._internal(
() => SyncChildren()..arg = arg,
from: syncChildrenProvider,
name: r'syncChildrenProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$syncChildrenHash,
dependencies: SyncChildrenFamily._dependencies,
allTransitiveDependencies:
SyncChildrenFamily._allTransitiveDependencies,
arg: arg,
);
SyncChildrenProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
}) : super.internal();
final SyncedItem arg;
@override
List<SyncedItem> runNotifierBuild(
covariant SyncChildren notifier,
) {
return notifier.build(
arg,
);
}
@override
Override overrideWith(SyncChildren Function() create) {
return ProviderOverride(
origin: this,
override: SyncChildrenProvider._internal(
() => create()..arg = arg,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
),
);
}
@override
AutoDisposeNotifierProviderElement<SyncChildren, List<SyncedItem>>
createElement() {
return _SyncChildrenProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SyncChildrenProvider && other.arg == arg;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SyncChildrenRef on AutoDisposeNotifierProviderRef<List<SyncedItem>> {
/// The parameter `arg` of this provider.
SyncedItem get arg;
}
class _SyncChildrenProviderElement
extends AutoDisposeNotifierProviderElement<SyncChildren, List<SyncedItem>>
with SyncChildrenRef {
_SyncChildrenProviderElement(super.provider);
@override
SyncedItem get arg => (origin as SyncChildrenProvider).arg;
}
String _$syncDownloadStatusHash() =>
r'5a0f8537a977c52e6083bd84265631ea5d160637';
abstract class _$SyncDownloadStatus
extends BuildlessAutoDisposeNotifier<DownloadStream?> {
late final SyncedItem arg;
DownloadStream? build(
SyncedItem arg,
);
}
/// See also [SyncDownloadStatus].
@ProviderFor(SyncDownloadStatus)
const syncDownloadStatusProvider = SyncDownloadStatusFamily();
/// See also [SyncDownloadStatus].
class SyncDownloadStatusFamily extends Family<DownloadStream?> {
/// See also [SyncDownloadStatus].
const SyncDownloadStatusFamily();
/// See also [SyncDownloadStatus].
SyncDownloadStatusProvider call(
SyncedItem arg,
) {
return SyncDownloadStatusProvider(
arg,
);
}
@override
SyncDownloadStatusProvider getProviderOverride(
covariant SyncDownloadStatusProvider provider,
) {
return call(
provider.arg,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'syncDownloadStatusProvider';
}
/// See also [SyncDownloadStatus].
class SyncDownloadStatusProvider extends AutoDisposeNotifierProviderImpl<
SyncDownloadStatus, DownloadStream?> {
/// See also [SyncDownloadStatus].
SyncDownloadStatusProvider(
SyncedItem arg,
) : this._internal(
() => SyncDownloadStatus()..arg = arg,
from: syncDownloadStatusProvider,
name: r'syncDownloadStatusProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$syncDownloadStatusHash,
dependencies: SyncDownloadStatusFamily._dependencies,
allTransitiveDependencies:
SyncDownloadStatusFamily._allTransitiveDependencies,
arg: arg,
);
SyncDownloadStatusProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
}) : super.internal();
final SyncedItem arg;
@override
DownloadStream? runNotifierBuild(
covariant SyncDownloadStatus notifier,
) {
return notifier.build(
arg,
);
}
@override
Override overrideWith(SyncDownloadStatus Function() create) {
return ProviderOverride(
origin: this,
override: SyncDownloadStatusProvider._internal(
() => create()..arg = arg,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
),
);
}
@override
AutoDisposeNotifierProviderElement<SyncDownloadStatus, DownloadStream?>
createElement() {
return _SyncDownloadStatusProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SyncDownloadStatusProvider && other.arg == arg;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SyncDownloadStatusRef on AutoDisposeNotifierProviderRef<DownloadStream?> {
/// The parameter `arg` of this provider.
SyncedItem get arg;
}
class _SyncDownloadStatusProviderElement
extends AutoDisposeNotifierProviderElement<SyncDownloadStatus,
DownloadStream?> with SyncDownloadStatusRef {
_SyncDownloadStatusProviderElement(super.provider);
@override
SyncedItem get arg => (origin as SyncDownloadStatusProvider).arg;
}
String _$syncStatusesHash() => r'f05ee53368d1de130714bba09132e08aba15bc44';
abstract class _$SyncStatuses
extends BuildlessAutoDisposeAsyncNotifier<SyncStatus> {
late final SyncedItem arg;
FutureOr<SyncStatus> build(
SyncedItem arg,
);
}
/// See also [SyncStatuses].
@ProviderFor(SyncStatuses)
const syncStatusesProvider = SyncStatusesFamily();
/// See also [SyncStatuses].
class SyncStatusesFamily extends Family<AsyncValue<SyncStatus>> {
/// See also [SyncStatuses].
const SyncStatusesFamily();
/// See also [SyncStatuses].
SyncStatusesProvider call(
SyncedItem arg,
) {
return SyncStatusesProvider(
arg,
);
}
@override
SyncStatusesProvider getProviderOverride(
covariant SyncStatusesProvider provider,
) {
return call(
provider.arg,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'syncStatusesProvider';
}
/// See also [SyncStatuses].
class SyncStatusesProvider
extends AutoDisposeAsyncNotifierProviderImpl<SyncStatuses, SyncStatus> {
/// See also [SyncStatuses].
SyncStatusesProvider(
SyncedItem arg,
) : this._internal(
() => SyncStatuses()..arg = arg,
from: syncStatusesProvider,
name: r'syncStatusesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$syncStatusesHash,
dependencies: SyncStatusesFamily._dependencies,
allTransitiveDependencies:
SyncStatusesFamily._allTransitiveDependencies,
arg: arg,
);
SyncStatusesProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
}) : super.internal();
final SyncedItem arg;
@override
FutureOr<SyncStatus> runNotifierBuild(
covariant SyncStatuses notifier,
) {
return notifier.build(
arg,
);
}
@override
Override overrideWith(SyncStatuses Function() create) {
return ProviderOverride(
origin: this,
override: SyncStatusesProvider._internal(
() => create()..arg = arg,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<SyncStatuses, SyncStatus>
createElement() {
return _SyncStatusesProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SyncStatusesProvider && other.arg == arg;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SyncStatusesRef on AutoDisposeAsyncNotifierProviderRef<SyncStatus> {
/// The parameter `arg` of this provider.
SyncedItem get arg;
}
class _SyncStatusesProviderElement
extends AutoDisposeAsyncNotifierProviderElement<SyncStatuses, SyncStatus>
with SyncStatusesRef {
_SyncStatusesProviderElement(super.provider);
@override
SyncedItem get arg => (origin as SyncStatusesProvider).arg;
}
String _$syncSizeHash() => r'138702f2dd69ab28d142bab67ab4a497bb24f252';
abstract class _$SyncSize extends BuildlessAutoDisposeNotifier<int?> {
late final SyncedItem arg;
int? build(
SyncedItem arg,
);
}
/// See also [SyncSize].
@ProviderFor(SyncSize)
const syncSizeProvider = SyncSizeFamily();
/// See also [SyncSize].
class SyncSizeFamily extends Family<int?> {
/// See also [SyncSize].
const SyncSizeFamily();
/// See also [SyncSize].
SyncSizeProvider call(
SyncedItem arg,
) {
return SyncSizeProvider(
arg,
);
}
@override
SyncSizeProvider getProviderOverride(
covariant SyncSizeProvider provider,
) {
return call(
provider.arg,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'syncSizeProvider';
}
/// See also [SyncSize].
class SyncSizeProvider extends AutoDisposeNotifierProviderImpl<SyncSize, int?> {
/// See also [SyncSize].
SyncSizeProvider(
SyncedItem arg,
) : this._internal(
() => SyncSize()..arg = arg,
from: syncSizeProvider,
name: r'syncSizeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$syncSizeHash,
dependencies: SyncSizeFamily._dependencies,
allTransitiveDependencies: SyncSizeFamily._allTransitiveDependencies,
arg: arg,
);
SyncSizeProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.arg,
}) : super.internal();
final SyncedItem arg;
@override
int? runNotifierBuild(
covariant SyncSize notifier,
) {
return notifier.build(
arg,
);
}
@override
Override overrideWith(SyncSize Function() create) {
return ProviderOverride(
origin: this,
override: SyncSizeProvider._internal(
() => create()..arg = arg,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
arg: arg,
),
);
}
@override
AutoDisposeNotifierProviderElement<SyncSize, int?> createElement() {
return _SyncSizeProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SyncSizeProvider && other.arg == arg;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, arg.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SyncSizeRef on AutoDisposeNotifierProviderRef<int?> {
/// The parameter `arg` of this provider.
SyncedItem get arg;
}
class _SyncSizeProviderElement
extends AutoDisposeNotifierProviderElement<SyncSize, int?>
with SyncSizeRef {
_SyncSizeProviderElement(super.provider);
@override
SyncedItem get arg => (origin as SyncSizeProvider).arg;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,627 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/syncing/download_stream.dart';
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/models/syncing/sync_settings_model.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync/background_download_provider.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:isar/isar.dart';
import 'package:path/path.dart' as path;
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/series_model.dart';
import 'package:fladder/models/video_stream_model.dart';
import 'package:fladder/profiles/default_profile.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/user_provider.dart';
final syncProvider = StateNotifierProvider<SyncNotifier, SyncSettingsModel>((ref) => throw UnimplementedError());
final downloadTasksProvider = StateProvider.family<DownloadStream, String>((ref, id) => DownloadStream.empty());
class SyncNotifier extends StateNotifier<SyncSettingsModel> {
SyncNotifier(this.ref, this.isar, this.mobileDirectory) : super(SyncSettingsModel()) {
_init();
}
void _init() {
ref.listen(
userProvider,
(previous, next) {
if (previous?.id != next?.id) {
if (next?.id != null) {
_initializeQueryStream(next?.id ?? "");
}
}
},
);
final userId = ref.read(userProvider)?.id;
if (userId != null) {
_initializeQueryStream(userId);
}
}
void _initializeQueryStream(String userId) {
_subscription?.cancel();
final queryStream = getParentSyncItems
?.userIdEqualTo(userId)
.watch()
.asyncMap((event) => event.map((e) => SyncedItem.fromIsar(e, syncPath ?? "")).toList());
final initItems = getParentSyncItems
?.userIdEqualTo(userId)
.findAll()
.mapIndexed((index, element) => SyncedItem.fromIsar(element, syncPath ?? ""))
.toList();
state = state.copyWith(items: initItems ?? []);
_subscription = queryStream?.listen((items) {
state = state.copyWith(items: items);
});
}
final Ref ref;
final Isar? isar;
final Directory mobileDirectory;
final String subPath = "Synced";
StreamSubscription<List<SyncedItem>>? _subscription;
IsarCollection<String, ISyncedItem>? get syncedItems => isar?.iSyncedItems;
late final JellyService api = ref.read(jellyApiProvider);
String? get _savePath => !kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)
? ref.read(clientSettingsProvider.select((value) => value.syncPath))
: mobileDirectory.path;
String? get savePath => _savePath;
Directory get mainDirectory => Directory(path.joinAll([_savePath ?? "", subPath]));
Directory? get saveDirectory {
if (kIsWeb) return null;
final directory = _savePath != null
? Directory(path.joinAll([_savePath ?? "", subPath, ref.read(userProvider)?.id ?? "UnknownUser"]))
: null;
directory?.createSync(recursive: true);
if (directory?.existsSync() == true) {
final noMedia = File(path.joinAll([directory?.path ?? "", ".nomedia"]));
noMedia.writeAsString('');
}
return directory;
}
String? get syncPath => saveDirectory?.path;
QueryBuilder<ISyncedItem, ISyncedItem, QAfterFilterCondition>? get getParentSyncItems =>
syncedItems?.where().parentIdIsNull();
Future<int> get directorySize async {
if (saveDirectory == null) return 0;
var files = await saveDirectory!.list(recursive: true).toList();
var dirSize = files.fold(0, (int sum, file) => sum + file.statSync().size);
return dirSize;
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
Future<void> refresh() async {
state = state.copyWith(
items: (await getParentSyncItems?.userIdEqualTo(ref.read(userProvider)?.id).findAllAsync())
?.map((e) => SyncedItem.fromIsar(e, syncPath ?? ""))
.toList() ??
[]);
}
List<SyncedItem> getNestedChildren(SyncedItem item) {
final allChildren = <SyncedItem>[];
List<SyncedItem> toProcess = [item];
while (toProcess.isNotEmpty) {
final currentLevel = toProcess.map(
(parent) {
final children = syncedItems?.where().parentIdEqualTo(parent.id).sortBySortKey().findAll();
return children?.map((e) => SyncedItem.fromIsar(e, ref.read(syncProvider.notifier).syncPath ?? "")) ??
<SyncedItem>[];
},
);
allChildren.addAll(currentLevel.expand((list) => list));
toProcess = currentLevel.expand((list) => list).toList();
}
return allChildren;
}
List<SyncedItem> getChildren(SyncedItem item) {
return (syncedItems?.where().parentIdEqualTo(item.id).sortBySortKey().findAll())
?.map(
(e) => SyncedItem.fromIsar(e, syncPath ?? ""),
)
.toList() ??
[];
}
SyncedItem? getSyncedItem(ItemBaseModel? item) {
final id = item?.id;
if (id == null) return null;
final newItem = syncedItems?.get(id);
if (newItem == null) return null;
return SyncedItem.fromIsar(newItem, syncPath ?? "");
}
SyncedItem? getParentItem(String id) {
ISyncedItem? newItem = syncedItems?.get(id);
while (newItem?.parentId != null) {
newItem = syncedItems?.get(newItem!.parentId!);
}
if (newItem == null) return null;
return SyncedItem.fromIsar(newItem, syncPath ?? "");
}
ItemBaseModel? getItem(SyncedItem? syncedItem) {
if (syncedItem == null) return null;
return syncedItem.createItemModel(ref);
}
Future<SyncedItem?> addSyncItem(BuildContext? context, ItemBaseModel item) async {
if (context == null) return null;
if (saveDirectory == null) {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(dialogTitle: 'Select downloads folder');
if (selectedDirectory?.isEmpty == true) {
fladderSnackbar(context, title: "No sync folder setup");
return null;
}
ref.read(clientSettingsProvider.notifier).setSyncPath(selectedDirectory);
}
fladderSnackbar(context, title: "Added ${item.detailedName(context)} for syncing");
final newSync = switch (item) {
EpisodeModel episode => await syncSeries(item.parentBaseModel, episode: episode),
SeriesModel series => await syncSeries(series),
MovieModel movie => await syncMovie(movie),
_ => null
};
fladderSnackbar(context,
title: newSync != null
? "Started syncing ${item.detailedName(context)}"
: "Unable to sync ${item.detailedName(context)}, type not supported?");
return newSync;
}
Future<bool> removeSync(SyncedItem? item) async {
try {
if (item == null) return false;
final nestedChildren = getNestedChildren(item);
state = state.copyWith(
items: state.items
.map(
(e) => e.copyWith(markedForDelete: e.id == item.id ? true : false),
)
.toList());
if (item.taskId != null) {
await ref.read(backgroundDownloaderProvider).cancelTaskWithId(item.taskId!);
}
final deleteFromDatabase = isar?.write((isar) => syncedItems?.deleteAll([...nestedChildren, item]
.map(
(e) => e.id,
)
.toList()));
if (deleteFromDatabase == 0) return false;
for (var i = 0; i < nestedChildren.length; i++) {
final element = nestedChildren[i];
if (element.taskId != null) {
await ref.read(backgroundDownloaderProvider).cancelTaskWithId(element.taskId!);
}
if (await element.directory.exists()) {
await element.directory.delete(recursive: true);
}
}
if (await item.directory.exists()) {
await item.directory.delete(recursive: true);
}
return true;
} catch (e) {
log('Error deleting synced item');
log(e.toString());
return false;
}
}
//Utility functions
Future<List<SubStreamModel>> saveExternalSubtitles(List<SubStreamModel>? subtitles, SyncedItem item) async {
if (subtitles == null) return [];
final directory = item.directory;
await directory.create(recursive: true);
return Stream.fromIterable(subtitles).asyncMap((element) async {
if (element.isExternal) {
final response = await http.get(Uri.parse(element.url!));
final file = File(path.joinAll([directory.path, "${element.displayTitle}.${element.language}.srt"]));
file.writeAsBytesSync(response.bodyBytes);
return element.copyWith(
url: () => file.path,
);
}
return element;
}).toList();
}
Future<TrickPlayModel?> saveTrickPlayData(ItemBaseModel? item, Directory saveDirectory) async {
if (item == null) return null;
final trickPlayDirectory = Directory(path.joinAll([saveDirectory.path, SyncedItem.trickPlayPath]))
..createSync(recursive: true);
final trickPlayData = await api.getTrickPlay(item: item, ref: ref);
final List<String> newStringList = [];
for (var index = 0; index < (trickPlayData?.body?.images.length ?? 0); index++) {
final image = trickPlayData?.body?.images[index];
if (image != null) {
final http.Response response = await http.get(Uri.parse(image));
File? newFile;
final fileName = "tile_$index.jpg";
if (response.statusCode == 200) {
final Uint8List bytes = response.bodyBytes;
newFile = File(path.joinAll([trickPlayDirectory.path, fileName]));
await newFile.writeAsBytes(bytes);
}
if (newFile != null && await newFile.exists()) {
newStringList.add(path.joinAll(['TrickPlay', fileName]));
}
}
}
return trickPlayData?.body?.copyWith(images: newStringList.toList());
}
Future<ImagesData?> saveImageData(ImagesData? data, Directory saveDirectory) async {
if (data == null) return data;
if (!saveDirectory.existsSync()) return data;
final primary = await urlDataToFileData(data.primary, saveDirectory, "primary.jpg");
final logo = await urlDataToFileData(data.logo, saveDirectory, "logo.jpg");
final backdrops = await Stream.fromIterable(data.backDrop ?? <ImageData>[])
.asyncMap((element) async => await urlDataToFileData(element, saveDirectory, "backdrop-${element.key}.jpg"))
.toList();
return data.copyWith(
primary: () => primary,
logo: () => logo,
backDrop: () => backdrops.whereNotNull().toList(),
);
}
Future<List<Chapter>?> saveChapterImages(List<Chapter>? data, Directory itemPath) async {
if (data == null) return data;
if (!itemPath.existsSync()) return data;
if (data.isEmpty) return data;
final saveDirectory = Directory(path.joinAll([itemPath.path, "Chapters"]));
await saveDirectory.create(recursive: true);
final saveChapters = await Stream.fromIterable(data).asyncMap((event) async {
final fileName = "${event.name}.jpg";
final response = await http.get(Uri.parse(event.imageUrl));
final file = File(path.joinAll([saveDirectory.path, fileName]));
if (response.bodyBytes.isEmpty) return null;
file.writeAsBytesSync(response.bodyBytes);
return event.copyWith(
imageUrl: path.joinAll(["Chapters", fileName]),
);
}).toList();
return saveChapters.whereNotNull().toList();
}
Future<ImageData?> urlDataToFileData(ImageData? data, Directory directory, String fileName) async {
if (data?.path == null) return null;
final response = await http.get(Uri.parse(data?.path ?? ""));
final file = File(path.joinAll([directory.path, fileName]));
file.writeAsBytesSync(response.bodyBytes);
return data?.copyWith(path: fileName);
}
void updateItemSync(SyncedItem syncedItem) =>
isar?.write((isar) => syncedItems?.put(ISyncedItem.fromSynced(syncedItem, syncPath ?? "")));
Future<void> updateItem(SyncedItem syncedItem) async {
isar?.write((isar) => syncedItems?.put(ISyncedItem.fromSynced(syncedItem, syncPath ?? "")));
}
Future<SyncedItem> deleteFullSyncFiles(SyncedItem syncedItem) async {
await syncedItem.deleteDatFiles(ref);
ref.read(downloadTasksProvider(syncedItem.id).notifier).update((state) => DownloadStream.empty());
refresh();
return syncedItem;
}
Future<DownloadStream?> syncVideoFile(SyncedItem syncItem, bool skipDownload) async {
final playbackResponse = await api.itemsItemIdPlaybackInfoPost(
itemId: syncItem.id,
body: PlaybackInfoDto(
enableDirectPlay: true,
enableDirectStream: true,
enableTranscoding: false,
deviceProfile: defaultProfile,
),
);
final item = syncItem.createItemModel(ref);
final directory = await Directory(syncItem.directory.path).create(recursive: true);
final newState = VideoStream.fromPlayBackInfo(playbackResponse.bodyOrThrow, ref)?.copyWith();
final subtitles = await saveExternalSubtitles(newState?.mediaStreamsModel?.subStreams, syncItem);
final trickPlayFile = await saveTrickPlayData(item, directory);
final introOutroSkip = (await api.introSkipGet(id: syncItem.id))?.body;
syncItem = syncItem.copyWith(
subtitles: subtitles,
fTrickPlayModel: trickPlayFile,
introOutSkipModel: introOutroSkip,
);
await updateItem(syncItem);
final currentTask = ref.read(downloadTasksProvider(syncItem.id));
final downloadString = path.joinAll([
"${ref.read(userProvider)?.server}",
"Items",
"${syncItem.id}/Download?api_key=${ref.read(userProvider)?.credentials.token}"
]);
try {
if (!skipDownload && currentTask.task == null) {
final downloadTask = DownloadTask(
url: downloadString,
directory: syncItem.directory.path,
filename: syncItem.videoFileName,
updates: Updates.statusAndProgress,
baseDirectory: BaseDirectory.root,
requiresWiFi: true,
retries: 5,
allowPause: true,
);
final defaultDownloadStream =
DownloadStream(id: syncItem.id, task: downloadTask, progress: 0.0, status: TaskStatus.enqueued);
ref.read(downloadTasksProvider(syncItem.id).notifier).update((state) => defaultDownloadStream);
ref.read(backgroundDownloaderProvider).download(
downloadTask,
onProgress: (progress) {
if (progress > 0 && progress < 1) {
ref.read(downloadTasksProvider(syncItem.id).notifier).update(
(state) => state.copyWith(progress: progress),
);
} else {
ref.read(downloadTasksProvider(syncItem.id).notifier).update(
(state) => state.copyWith(progress: null),
);
}
},
onStatus: (status) {
ref.read(downloadTasksProvider(syncItem.id).notifier).update(
(state) => state.copyWith(status: status),
);
if (status == TaskStatus.complete) {
ref.read(downloadTasksProvider(syncItem.id).notifier).update((state) => DownloadStream.empty());
}
},
);
return defaultDownloadStream;
}
} catch (e) {
log(e.toString());
return null;
}
return null;
}
Future<void> clear() async {
await mainDirectory.delete(recursive: true);
isar?.write((isar) => syncedItems?.clear());
state = state.copyWith(items: []);
}
Future<void> setup() async {
state = state.copyWith(items: []);
_init();
}
}
extension SyncNotifierHelpers on SyncNotifier {
Future<SyncedItem> createSyncItem(BaseItemDto response, {SyncedItem? parent}) async {
final ItemBaseModel item = ItemBaseModel.fromBaseDto(response, ref);
final Directory? parentDirectory = parent?.directory;
SyncedItem syncItem = SyncedItem(id: item.id, userId: ref.read(userProvider)?.id ?? "");
final directory = Directory(path.joinAll([(parentDirectory ?? saveDirectory)?.path ?? "", item.id]));
await directory.create(recursive: true);
File dataFile = File(path.joinAll([directory.path, 'data.json']));
await dataFile.writeAsString(jsonEncode(response.toJson()));
final imageData = await saveImageData(item.images, directory);
final origChapters = Chapter.chaptersFromInfo(item.id, response.chapters ?? [], ref);
return syncItem.copyWith(
id: item.id,
parentId: parent?.id,
sortKey: (response.parentIndexNumber ?? 0) * (response.indexNumber ?? 0),
path: directory.path,
fChapters: await saveChapterImages(origChapters, directory) ?? [],
fileSize: response.mediaSources?.firstOrNull?.size ?? 0,
fImages: imageData,
videoFileName: response.path?.split('/').lastOrNull ?? "",
userData: item.userData,
);
}
// Need to move the file after downloading on Android
Future<void> moveFile(DownloadTask downloadTask, SyncedItem syncItem) async {
final currentLocation = File(await downloadTask.filePath());
final wantedLocation = syncItem.videoFile;
if (currentLocation.path != wantedLocation.path) {
await currentLocation.copy(wantedLocation.path);
await currentLocation.delete();
}
}
Future<SyncedItem?> syncMovie(ItemBaseModel item, {bool skipDownload = false}) async {
final response = await api.usersUserIdItemsItemIdGetBaseItem(
itemId: item.id,
);
final itemBaseModel = response.body;
if (itemBaseModel == null) return null;
SyncedItem syncItem = await createSyncItem(itemBaseModel);
if (!syncItem.directory.existsSync()) return null;
await syncVideoFile(syncItem, skipDownload);
await isar?.writeAsync((isar) => syncedItems?.put(ISyncedItem.fromSynced(syncItem, syncPath)));
return syncItem;
}
Future<SyncedItem?> syncSeries(SeriesModel item, {EpisodeModel? episode}) async {
final response = await api.usersUserIdItemsItemIdGetBaseItem(
itemId: item.id,
);
List<SyncedItem> newItems = [];
SyncedItem? itemToDownload;
SyncedItem seriesItem = await createSyncItem(response.bodyOrThrow);
newItems.add(seriesItem);
if (!seriesItem.directory.existsSync()) return null;
final seasonsResponse = await api.showsSeriesIdSeasonsGet(
isMissing: false,
enableUserData: true,
fields: [
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.overview,
ItemFields.mediasourcecount,
ItemFields.airtime,
ItemFields.datecreated,
ItemFields.datelastmediaadded,
ItemFields.datelastrefreshed,
ItemFields.sortname,
ItemFields.seasonuserdata,
ItemFields.externalurls,
ItemFields.genres,
ItemFields.parentid,
ItemFields.path,
ItemFields.chapters,
ItemFields.trickplay,
],
seriesId: item.id,
);
final seasons = seasonsResponse.body?.items ?? [];
for (var i = 0; i < seasons.length; i++) {
final season = seasons[i];
final syncedSeason = await createSyncItem(season, parent: seriesItem);
newItems.add(syncedSeason);
final episodesResponse = await api.showsSeriesIdEpisodesGet(
isMissing: false,
enableUserData: true,
fields: [
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.overview,
ItemFields.mediasourcecount,
ItemFields.airtime,
ItemFields.datecreated,
ItemFields.datelastmediaadded,
ItemFields.datelastrefreshed,
ItemFields.sortname,
ItemFields.seasonuserdata,
ItemFields.externalurls,
ItemFields.genres,
ItemFields.parentid,
ItemFields.path,
ItemFields.chapters,
ItemFields.trickplay,
],
seasonId: season.id,
seriesId: seriesItem.id,
);
final episodes = episodesResponse.body?.items ?? [];
for (var i = 0; i < episodes.length; i++) {
final item = episodes[i];
final newEpisode = await createSyncItem(item, parent: syncedSeason);
newItems.add(newEpisode);
if (episode?.id == item.id) {
itemToDownload = newEpisode;
}
}
}
isar?.write(
(isar) => syncedItems?.putAll(newItems
.map(
(e) => ISyncedItem.fromSynced(e, syncPath ?? ""),
)
.toList()),
);
if (itemToDownload != null) {
await syncVideoFile(itemToDownload, false);
}
return seriesItem;
}
}

View file

@ -0,0 +1,13 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
class AbstractSyncNotifier {
final Ref ref;
final Isar? isar;
final Directory mobileDirectory;
final String subPath = "Synced";
AbstractSyncNotifier(this.ref, this.isar, this.mobileDirectory);
}

View file

@ -0,0 +1,145 @@
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/enum_models.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'user_provider.g.dart';
@riverpod
bool showSyncButtonProvider(ShowSyncButtonProviderRef ref) {
final userCanSync = ref.watch(userProvider.select((value) => value?.canDownload ?? false));
final hasSyncedItems = ref.watch(syncProvider.select((value) => value.items.isNotEmpty));
return userCanSync || hasSyncedItems;
}
@Riverpod(keepAlive: true)
class User extends _$User {
late final JellyService api = ref.read(jellyApiProvider);
set userState(AccountModel? account) {
state = account?.copyWith(lastUsed: DateTime.now());
if (account != null) {
ref.read(sharedUtilityProvider).updateAccountInfo(account);
}
}
Future<Response<bool>> quickConnect(String pin) async => api.quickConnect(pin);
Future<Response<AccountModel>?> updateInformation() async {
if (state == null) return null;
var response = await api.usersMeGet();
var quickConnectStatus = await api.quickConnectEnabled();
var systemConfiguration = await api.systemConfigurationGet();
if (response.isSuccessful && response.body != null) {
userState = state?.copyWith(
name: response.body?.name ?? state?.name ?? "",
policy: response.body?.policy,
serverConfiguration: systemConfiguration.body,
quickConnectState: quickConnectStatus.body ?? false,
latestItemsExcludes: response.body?.configuration?.latestItemsExcludes ?? [],
);
return response.copyWith(body: state);
}
return null;
}
Future<Response> refreshMetaData(
String itemId, {
MetadataRefresh? metadataRefreshMode,
bool? replaceAllMetadata,
}) async {
return api.itemsItemIdRefreshPost(
itemId: itemId,
metadataRefreshMode: metadataRefreshMode,
imageRefreshMode: metadataRefreshMode,
replaceAllMetadata: switch (metadataRefreshMode) {
MetadataRefresh.fullRefresh => false,
MetadataRefresh.validation => true,
_ => false,
},
replaceAllImages: switch (metadataRefreshMode) {
MetadataRefresh.fullRefresh => true,
MetadataRefresh.validation => true,
_ => false,
},
);
}
Future<Response<UserData>?> setAsFavorite(bool favorite, String itemId) async {
final response = await (favorite
? api.usersUserIdFavoriteItemsItemIdPost(itemId: itemId)
: api.usersUserIdFavoriteItemsItemIdDelete(itemId: itemId));
return Response(response.base, UserData.fromDto(response.body));
}
Future<Response<UserData>?> markAsPlayed(bool enable, String itemId) async {
final response = await (enable
? api.usersUserIdPlayedItemsItemIdPost(
itemId: itemId,
datePlayed: DateTime.now(),
)
: api.usersUserIdPlayedItemsItemIdDelete(
itemId: itemId,
));
return Response(response.base, UserData.fromDto(response.body));
}
void clear() {
userState = null;
}
void updateUser(AccountModel? user) {
userState = user;
}
void loginUser(AccountModel? user) {
state = user;
}
void setAuthMethod(Authentication method) {
userState = state?.copyWith(authMethod: method);
}
void addSearchQuery(String value) {
if (value.isEmpty) return;
final newList = state?.searchQueryHistory.toList() ?? [];
if (newList.contains(value)) {
newList.remove(value);
}
newList.add(value);
userState = state?.copyWith(searchQueryHistory: newList);
}
void removeSearchQuery(String value) {
userState = state?.copyWith(
searchQueryHistory: state?.searchQueryHistory ?? []
..remove(value)
..take(50),
);
}
void clearSearchQuery() {
userState = state?.copyWith(searchQueryHistory: []);
}
Future<void> logoutUser() async {
if (state == null) return;
userState = null;
}
Future<void> forceLogoutUser(AccountModel account) async {
userState = account;
await api.sessionsLogoutPost();
userState = null;
}
@override
AccountModel? build() {
return null;
}
}

View file

@ -0,0 +1,40 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$showSyncButtonProviderHash() =>
r'3468d7309f3859f7b60b1bd317e306e1f5f00555';
/// See also [showSyncButtonProvider].
@ProviderFor(showSyncButtonProvider)
final showSyncButtonProviderProvider = AutoDisposeProvider<bool>.internal(
showSyncButtonProvider,
name: r'showSyncButtonProviderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$showSyncButtonProviderHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ShowSyncButtonProviderRef = AutoDisposeProviderRef<bool>;
String _$userHash() => r'4a4302c819d26fc7c28d04b9274d0dfd0dc8e201';
/// See also [User].
@ProviderFor(User)
final userProvider = NotifierProvider<User, AccountModel?>.internal(
User.new,
name: r'userProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$userHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$User = Notifier<AccountModel?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,125 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/wrappers/media_control_wrapper.dart'
if (dart.library.html) 'package:fladder/wrappers/media_control_wrapper_web.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
final mediaPlaybackProvider = StateProvider<MediaPlaybackModel>((ref) => MediaPlaybackModel());
final playBackModel = StateProvider<PlaybackModel?>((ref) => null);
final videoPlayerProvider = StateNotifierProvider<VideoPlayerNotifier, MediaControlsWrapper>((ref) {
final videoPlayer = VideoPlayerNotifier(ref);
videoPlayer.init();
return videoPlayer;
});
class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
VideoPlayerNotifier(this.ref) : super(MediaControlsWrapper(ref: ref));
final Ref ref;
List<StreamSubscription> subscriptions = [];
late final mediaState = ref.read(mediaPlaybackProvider.notifier);
bool initMediaControls = false;
void init() async {
state.player?.dispose();
if (!initMediaControls && !kDebugMode) {
state.init();
initMediaControls = true;
}
for (final s in subscriptions) {
s.cancel();
}
final player = state.setup();
if (player.platform is NativePlayer) {
await (player.platform as dynamic).setProperty(
'force-seekable',
'yes',
);
}
subscriptions.addAll(
[
player.stream.buffering.listen((event) => mediaState.update((state) => state.copyWith(buffering: event))),
player.stream.buffer.listen((event) => mediaState.update((state) => state.copyWith(buffer: event))),
player.stream.playing.listen((event) => updatePlaying(event)),
player.stream.position.listen((event) => updatePosition(event)),
player.stream.duration.listen((event) => mediaState.update((state) => state.copyWith(duration: event))),
].whereNotNull(),
);
}
Future<void> updatePlaying(bool event) async {
final player = state.player;
if (player == null) return;
mediaState.update((state) => state.copyWith(playing: event));
}
Future<void> updatePosition(Duration event) async {
final player = state.player;
if (player == null) return;
if (!player.state.playing) return;
final position = event;
final lastPosition = ref.read(mediaPlaybackProvider.select((value) => value.lastPosition));
final diff = (position.inMilliseconds - lastPosition.inMilliseconds).abs();
if (diff > Duration(seconds: 1, milliseconds: 500).inMilliseconds) {
mediaState.update((value) => value.copyWith(
position: event,
playing: player.state.playing,
duration: player.state.duration,
lastPosition: position,
));
ref.read(playBackModel)?.updatePlaybackPosition(position, player.state.playing, ref);
} else {
mediaState.update((value) => value.copyWith(
position: event,
playing: player.state.playing,
duration: player.state.duration,
));
}
}
Future<bool> loadPlaybackItem(PlaybackModel model, {Duration? startPosition}) async {
await state.stop();
mediaState
.update((state) => state.copyWith(state: VideoPlayerState.fullScreen, buffering: true, errorPlaying: false));
final media = model.media;
PlaybackModel? newPlaybackModel = model;
if (media != null) {
await state.setVolume(ref.read(videoPlayerSettingsProvider).volume);
await state.open(media, play: false);
state.player?.stream.buffering.takeWhile((event) => event == true).listen(
null,
onDone: () async {
final start = startPosition ?? await model.startDuration();
if (start != null) {
await state.seek(start);
}
newPlaybackModel = await newPlaybackModel?.setAudio(null, state);
newPlaybackModel = await newPlaybackModel?.setSubtitle(null, state);
ref.read(playBackModel.notifier).update((state) => newPlaybackModel);
state.play();
},
);
ref.read(playBackModel.notifier).update((state) => model);
return true;
}
mediaState.update((state) => state.copyWith(errorPlaying: true));
return false;
}
}

View file

@ -0,0 +1,64 @@
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/view_model.dart';
import 'package:fladder/models/views_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final viewsProvider = StateNotifierProvider<ViewsNotifier, ViewsModel>((ref) {
return ViewsNotifier(ref);
});
class ViewsNotifier extends StateNotifier<ViewsModel> {
ViewsNotifier(this.ref) : super(ViewsModel());
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<void> fetchViews() async {
if (state.loading) return;
final response = await api.usersUserIdViewsGet();
final createdViews = response.body?.items?.map((e) => ViewModel.fromBodyDto(e, ref));
List<ViewModel> newList = [];
if (createdViews != null) {
newList = await Future.wait(createdViews.map((e) async {
if (ref.read(userProvider)?.latestItemsExcludes.contains(e.id) == true) return e;
if ([CollectionType.boxsets, CollectionType.folders].contains(e.collectionType)) return e;
final recents = await api.usersUserIdItemsLatestGet(
parentId: e.id,
imageTypeLimit: 1,
limit: 16,
enableImageTypes: [
ImageType.primary,
ImageType.backdrop,
ImageType.thumb,
],
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
],
);
return e.copyWith(recentlyAdded: recents.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList());
}));
}
state = state.copyWith(
views: newList,
dashboardViews: newList
.where((element) => !(ref.read(userProvider)?.latestItemsExcludes.contains(element.id) ?? true))
.where((element) => ![CollectionType.boxsets, CollectionType.folders].contains(element.collectionType))
.toList(),
loading: false);
}
void clear() {
state = ViewsModel();
}
}