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,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AbsorbEvents extends ConsumerWidget {
final bool absorb;
final Widget child;
const AbsorbEvents({super.key, required this.child, this.absorb = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (absorb) {
return GestureDetector(
onDoubleTap: () {},
onTap: () {},
onLongPress: () {},
child: Container(color: Colors.black.withOpacity(0), child: child),
);
} else {
return child;
}
}
}

View file

@ -0,0 +1,193 @@
import 'package:fladder/util/poster_defaults.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:fladder/routes/app_routes.dart';
enum LayoutState {
phone,
tablet,
desktop,
}
enum ScreenLayout {
single,
dual,
}
enum InputDevice {
touch,
pointer,
}
class LayoutPoints {
final double start;
final double end;
final LayoutState type;
LayoutPoints({
required this.start,
required this.end,
required this.type,
});
LayoutPoints copyWith({
double? start,
double? end,
LayoutState? type,
}) {
return LayoutPoints(
start: start ?? this.start,
end: end ?? this.end,
type: type ?? this.type,
);
}
@override
String toString() => 'LayoutPoints(start: $start, end: $end, type: $type)';
@override
bool operator ==(covariant LayoutPoints other) {
if (identical(this, other)) return true;
return other.start == start && other.end == end && other.type == type;
}
@override
int get hashCode => start.hashCode ^ end.hashCode ^ type.hashCode;
}
class AdaptiveLayout extends InheritedWidget {
final LayoutState layout;
final ScreenLayout size;
final InputDevice inputDevice;
final TargetPlatform platform;
final bool isDesktop;
final GoRouter router;
final PosterDefaults posterDefaults;
const AdaptiveLayout({
super.key,
required this.layout,
required this.size,
required this.inputDevice,
required this.platform,
required this.isDesktop,
required this.router,
required this.posterDefaults,
required super.child,
});
static AdaptiveLayout? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AdaptiveLayout>();
}
static LayoutState layoutOf(BuildContext context) {
final AdaptiveLayout? result = maybeOf(context);
return result!.layout;
}
static PosterDefaults poster(BuildContext context) {
final AdaptiveLayout? result = maybeOf(context);
return result!.posterDefaults;
}
static GoRouter routerOf(BuildContext context) {
final AdaptiveLayout? result = maybeOf(context);
return result!.router;
}
static AdaptiveLayout of(BuildContext context) {
final AdaptiveLayout? result = maybeOf(context);
return result!;
}
@override
bool updateShouldNotify(AdaptiveLayout oldWidget) {
return layout != oldWidget.layout ||
size != oldWidget.size ||
platform != oldWidget.platform ||
inputDevice != oldWidget.inputDevice ||
isDesktop != oldWidget.isDesktop ||
router != oldWidget.router;
}
}
class AdaptiveLayoutBuilder extends ConsumerStatefulWidget {
final List<LayoutPoints> layoutPoints;
final LayoutState fallBack;
final Widget child;
const AdaptiveLayoutBuilder({required this.layoutPoints, required this.child, required this.fallBack, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _AdaptiveLayoutBuilderState();
}
class _AdaptiveLayoutBuilderState extends ConsumerState<AdaptiveLayoutBuilder> {
late LayoutState layout = widget.fallBack;
late ScreenLayout size = ScreenLayout.single;
late GoRouter router = AppRoutes.routes(ref: ref, screenLayout: size);
late TargetPlatform currentPlatform = defaultTargetPlatform;
@override
void didChangeDependencies() {
calculateLayout();
calculateSize();
super.didChangeDependencies();
}
bool get isDesktop {
if (kIsWeb) return false;
return [
TargetPlatform.macOS,
TargetPlatform.windows,
TargetPlatform.linux,
].contains(currentPlatform);
}
void calculateLayout() {
LayoutState? newType;
for (var element in widget.layoutPoints) {
if (MediaQuery.of(context).size.width > element.start && MediaQuery.of(context).size.width < element.end) {
newType = element.type;
}
}
if (newType == LayoutState.phone && isDesktop) {
newType = LayoutState.tablet;
}
layout = newType ?? widget.fallBack;
}
void calculateSize() {
ScreenLayout newSize;
if (MediaQuery.of(context).size.width > 0 && MediaQuery.of(context).size.width < 960 && !isDesktop) {
newSize = ScreenLayout.single;
} else {
newSize = ScreenLayout.dual;
}
if (size != newSize) {
size = newSize;
router = AppRoutes.routes(ref: ref, screenLayout: size);
}
}
@override
Widget build(BuildContext context) {
return AdaptiveLayout(
layout: layout,
size: size,
inputDevice: (isDesktop || kIsWeb) ? InputDevice.pointer : InputDevice.touch,
platform: currentPlatform,
isDesktop: isDesktop,
router: router,
posterDefaults: switch (layout) {
LayoutState.phone => PosterDefaults(size: 300, ratio: 0.55),
LayoutState.tablet => PosterDefaults(size: 350, ratio: 0.55),
LayoutState.desktop => PosterDefaults(size: 400, ratio: 0.55),
},
child: widget.child,
);
}
}

View file

@ -0,0 +1,38 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:flutter_riverpod/flutter_riverpod.dart';
final applicationInfoProvider = StateProvider<ApplicationInfo>((ref) {
return ApplicationInfo(
name: "",
version: "",
os: "",
);
});
class ApplicationInfo {
final String name;
final String version;
final String os;
ApplicationInfo({
required this.name,
required this.version,
required this.os,
});
ApplicationInfo copyWith({
String? name,
String? version,
String? os,
}) {
return ApplicationInfo(
name: name ?? this.name,
version: version ?? this.version,
os: os ?? this.os,
);
}
String get versionAndPlatform => "$version ($os)";
@override
String toString() => 'ApplicationInfo(name: $name, version: $version, os: $os)';
}

View file

@ -0,0 +1,36 @@
// ignore_for_file: depend_on_referenced_packages
import 'package:fladder/models/account_model.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:local_auth_android/local_auth_android.dart';
import 'package:local_auth_darwin/local_auth_darwin.dart';
class AuthService {
static Future<bool> authenticateUser(BuildContext context, AccountModel user) async {
final LocalAuthentication localAuthentication = LocalAuthentication();
bool isAuthenticated = false;
bool isBiometricSupported = await localAuthentication.isDeviceSupported();
bool canCheckBiometrics = await localAuthentication.canCheckBiometrics;
if (isBiometricSupported && canCheckBiometrics) {
try {
isAuthenticated = await localAuthentication.authenticate(
localizedReason:
context.localized.scanYourFingerprintToAuthenticate("(${user.name} - ${user.credentials.serverName})"),
authMessages: <AuthMessages>[
AndroidAuthMessages(
signInTitle: 'Fladder',
biometricHint: context.localized.scanBiometricHint,
),
IOSAuthMessages(
cancelButton: context.localized.cancel,
)
],
);
} on PlatformException catch (_) {}
}
return isAuthenticated;
}
}

View file

@ -0,0 +1,16 @@
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
extension BoxFitExtension on BoxFit {
String label(BuildContext context) {
return switch (this) {
BoxFit.fill => context.localized.videoScalingFill,
BoxFit.contain => context.localized.videoScalingContain,
BoxFit.cover => context.localized.videoScalingCover,
BoxFit.fitWidth => context.localized.videoScalingFitWidth,
BoxFit.fitHeight => context.localized.videoScalingFitHeight,
BoxFit.none => context.localized.none,
BoxFit.scaleDown => context.localized.videoScalingScaleDown,
};
}
}

View file

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
enum ColorThemes {
fladder(
name: 'Fladder',
color: Colors.orange,
),
deepOrange(
name: 'Deep Orange',
color: Colors.deepOrange,
),
amber(
name: 'Amber',
color: Colors.amber,
),
green(
name: 'Green',
color: Colors.green,
),
lightGreen(
name: 'Light Green',
color: Colors.lightGreen,
),
lime(
name: 'Lime',
color: Colors.lime,
),
cyan(
name: 'Cyan',
color: Colors.cyan,
),
blue(
name: 'Blue',
color: Colors.blue,
),
lightBlue(
name: 'Light Blue',
color: Colors.lightBlue,
),
indigo(
name: 'Indigo',
color: Colors.indigo,
),
deepBlue(
name: 'Deep Blue',
color: Color.fromARGB(255, 1, 34, 94),
),
brown(
name: 'Brown',
color: Colors.brown,
),
purple(
name: 'Purple',
color: Colors.purple,
),
deepPurple(
name: 'Deep Purple',
color: Colors.deepPurple,
),
blueGrey(
name: 'Blue Grey',
color: Colors.blueGrey,
),
;
const ColorThemes({
required this.name,
required this.color,
});
final String name;
final Color color;
ColorScheme get schemeLight {
return ColorScheme.fromSeed(seedColor: color, brightness: Brightness.light);
}
ColorScheme get schemeDark {
return ColorScheme.fromSeed(seedColor: color, brightness: Brightness.dark);
}
}

14
lib/util/debouncer.dart Normal file
View file

@ -0,0 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
class Debouncer {
Debouncer(this.duration);
final Duration duration;
Timer? _timer;
void run(VoidCallback action) {
if (_timer?.isActive ?? false) {
_timer?.cancel();
}
_timer = Timer(duration, action);
}
}

View file

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
class DisableFocus extends StatelessWidget {
final Widget child;
final bool canRequestFocus;
final bool skipTraversal;
final bool descendantsAreFocusable;
final bool descendantsAreTraversable;
const DisableFocus({
required this.child,
super.key,
this.canRequestFocus = false,
this.skipTraversal = true,
this.descendantsAreFocusable = false,
this.descendantsAreTraversable = false,
});
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: canRequestFocus,
skipTraversal: skipTraversal,
descendantsAreFocusable: descendantsAreFocusable,
descendantsAreTraversable: descendantsAreTraversable,
child: child,
);
}
}

View file

@ -0,0 +1,17 @@
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto;
extension DurationExtensions on Duration {
int get toRuntimeTicks => inMilliseconds * 10000;
String get readAbleDuration {
String twoDigits(int n) => n.toString().padLeft(2, "0");
return "${inHours != 0 ? '${twoDigits(inHours)}:' : ''}${twoDigits(inMinutes.remainder(60))}:${twoDigits(inSeconds.remainder(60))}";
}
}
extension BaseItemDtoExtension on dto.BaseItemDto {
Duration? get runTimeDuration {
if (runTimeTicks == null) return null;
return Duration(milliseconds: (runTimeTicks! ~/ 10000));
}
}

View file

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class FloatingActionButtonAnimated extends ConsumerWidget {
final Widget label;
final Widget icon;
final String tooltip;
final bool alternate;
final bool isExtended;
final void Function()? onPressed;
const FloatingActionButtonAnimated({
required this.label,
required this.icon,
required this.tooltip,
this.alternate = false,
this.isExtended = false,
required this.onPressed,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return FloatingActionButton.extended(
key: key,
tooltip: tooltip,
onPressed: onPressed,
foregroundColor: alternate ? Theme.of(context).colorScheme.onSecondary : null,
backgroundColor: alternate ? Theme.of(context).colorScheme.secondary : null,
extendedPadding: EdgeInsets.all(14),
label: AnimatedSize(
duration: const Duration(milliseconds: 250),
child: isExtended
? Row(
children: [
icon,
const SizedBox(width: 6),
label,
],
)
: icon,
),
);
}
}

View file

@ -0,0 +1,63 @@
import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:transparent_image/transparent_image.dart';
class FladderImage extends ConsumerWidget {
final ImageData? image;
final Widget Function(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded)? frameBuilder;
final Widget? placeHolder;
final BoxFit fit;
final bool enableBlur;
final bool blurOnly;
const FladderImage({
required this.image,
this.frameBuilder,
this.placeHolder,
this.fit = BoxFit.cover,
this.enableBlur = false,
this.blurOnly = false,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final useBluredPlaceHolder = ref.watch(clientSettingsProvider.select((value) => value.blurPlaceHolders));
final newImage = image;
final blurSize = AdaptiveLayout.of(context).isDesktop ? 32 : 16;
if (newImage == null) {
return placeHolder ?? Container();
} else {
return Stack(
key: Key(newImage.key),
fit: StackFit.expand,
children: [
if (useBluredPlaceHolder && !enableBlur && newImage.hash.isNotEmpty && !enableBlur)
Image(
fit: fit,
excludeFromSemantics: true,
filterQuality: FilterQuality.low,
image: BlurHashImage(
newImage.hash,
decodingWidth: blurSize,
decodingHeight: blurSize,
),
),
if (!blurOnly)
FadeInImage(
placeholder: Image.memory(kTransparentImage).image,
fit: fit,
placeholderFit: fit,
excludeFromSemantics: true,
filterQuality: FilterQuality.high,
placeholderFilterQuality: FilterQuality.low,
image: newImage.imageProvider,
)
],
);
}
}
}

14
lib/util/grouping.dart Normal file
View file

@ -0,0 +1,14 @@
import 'package:fladder/models/item_base_model.dart';
Map<String, List<ItemBaseModel>> groupByName(List<ItemBaseModel> items) {
Map<String, List<ItemBaseModel>> groupedItems = {};
for (int i = 0; i < items.length; i++) {
String firstLetter = items[i].name.replaceAll('The ', '')[0].toUpperCase();
if (!groupedItems.containsKey(firstLetter)) {
groupedItems[firstLetter] = [items[i]];
} else {
groupedItems[firstLetter]?.add(items[i]);
}
}
return groupedItems;
}

View file

@ -0,0 +1,11 @@
import 'package:fladder/util/application_info.dart';
import 'package:xid/xid.dart';
Map<String, String> generateHeader(ApplicationInfo application) {
var xid = Xid();
return {
'content-type': 'application/json',
'x-emby-authorization':
'MediaBrowser Client="${application.name}", Device="${application.os}", DeviceId="$xid", Version="${application.version}"',
};
}

View file

@ -0,0 +1,28 @@
import 'package:collection/collection.dart';
extension DurationExtensions on Duration? {
String? get humanize {
if (this == null) return null;
final duration = this!;
final hours = duration.inHours != 0 ? '${duration.inHours.toString()}h' : null;
final minutes = duration.inMinutes % 60 != 0 ? '${duration.inMinutes % 60}m'.padLeft(3, '0') : null;
final seconds = duration.inHours == 0 ? '${duration.inSeconds % 60}s'.padLeft(3, '0') : null;
final result = [hours, minutes, seconds].whereNotNull().map((e) => e).join(' ');
return result.isNotEmpty ? result : null;
}
String? get humanizeSmall {
if (this == null) return null;
final duration = this!;
final hours = (duration.inHours != 0 ? duration.inHours : null)?.toString();
final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0');
final seconds = (duration.inHours == 0 ? duration.inSeconds % 60 : null)?.toString().padLeft(2, '0');
final result = [hours, minutes, seconds].whereNotNull().map((e) => e).join(':');
return result.isNotEmpty ? result : null;
}
String get simpleTime {
return toString().split('.').first.padLeft(8, "0");
}
}

View file

@ -0,0 +1,250 @@
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/models/book_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/collections/add_to_collection.dart';
import 'package:fladder/screens/metadata/edit_item.dart';
import 'package:fladder/screens/metadata/identifty_screen.dart';
import 'package:fladder/screens/metadata/info_screen.dart';
import 'package:fladder/screens/playlists/add_to_playlists.dart';
import 'package:fladder/screens/metadata/refresh_metadata.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/screens/syncing/sync_button.dart';
import 'package:fladder/screens/syncing/sync_item_details.dart';
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/widgets/pop_up/delete_file.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
extension ItemBaseModelsBooleans on List<ItemBaseModel> {
Map<FladderItemType, List<ItemBaseModel>> get groupedItems {
Map<FladderItemType, List<ItemBaseModel>> groupedItems = {};
for (int i = 0; i < length; i++) {
FladderItemType type = this[i].type;
if (!groupedItems.containsKey(type)) {
groupedItems[type] = [this[i]];
} else {
groupedItems[type]?.add(this[i]);
}
}
return groupedItems;
}
}
enum ItemActions {
play,
openShow,
openParent,
details,
showAlbum,
playFromStart,
addCollection,
addPlaylist,
markPlayed,
markUnplayed,
setFavorite,
refreshMetaData,
editMetaData,
mediaInfo,
identify,
download,
}
extension ItemBaseModelExtensions on ItemBaseModel {
List<ItemAction> generateActions(
BuildContext context,
WidgetRef ref, {
List<ItemAction> otherActions = const [],
Set<ItemActions> exclude = const {},
Function(UserData? newData)? onUserDataChanged,
Function(ItemBaseModel item)? onItemUpdated,
Function(ItemBaseModel item)? onDeleteSuccesFully,
}) {
final isAdmin = ref.read(userProvider)?.policy?.isAdministrator ?? false;
final downloadEnabled = ref.read(userProvider.select(
(value) => value?.canDownload ?? false,
)) &&
syncAble &&
(canDownload ?? false);
final syncedItem = ref.read(syncProvider.notifier).getSyncedItem(this);
return [
if (!exclude.contains(ItemActions.play))
if (playAble)
ItemActionButton(
action: () => play(context, ref),
icon: Icon(IconsaxOutline.play),
label: Text(playButtonLabel(context)),
),
if (parentId?.isNotEmpty == true) ...[
if (!exclude.contains(ItemActions.openShow) && this is EpisodeModel)
ItemActionButton(
icon: Icon(FladderItemType.series.icon),
action: () => parentBaseModel.navigateTo(context),
label: Text(context.localized.openShow),
),
if (!exclude.contains(ItemActions.openParent) && this is! EpisodeModel && !galleryItem)
ItemActionButton(
icon: Icon(FladderItemType.folder.icon),
action: () => parentBaseModel.navigateTo(context),
label: Text(context.localized.openParent),
),
],
if (!galleryItem && !exclude.contains(ItemActions.details))
ItemActionButton(
action: () async => await navigateTo(context),
icon: Icon(IconsaxOutline.main_component),
label: Text(context.localized.showDetails),
)
else if (!exclude.contains(ItemActions.showAlbum) && galleryItem)
ItemActionButton(
icon: Icon(FladderItemType.photoalbum.icon),
action: () => (this as PhotoModel).navigateToAlbum(context),
label: Text(context.localized.showAlbum),
),
if (!exclude.contains(ItemActions.playFromStart))
if ((userData.progress) > 0)
ItemActionButton(
icon: Icon(IconsaxOutline.refresh),
action: (this is BookModel)
? () => ((this as BookModel).play(context, ref, currentPage: 0))
: () => play(context, ref, startPosition: Duration.zero),
label: Text((this is BookModel)
? context.localized.readFromStart(name)
: context.localized.playFromStart(subTextShort(context) ?? name)),
),
ItemActionDivider(),
if (!exclude.contains(ItemActions.addCollection))
if (type != FladderItemType.boxset)
ItemActionButton(
icon: Icon(IconsaxOutline.archive_add),
action: () async {
await addItemToCollection(context, [this]);
if (context.mounted) {
context.refreshData();
}
},
label: Text(context.localized.addToCollection),
),
if (!exclude.contains(ItemActions.addPlaylist))
if (type != FladderItemType.playlist)
ItemActionButton(
icon: Icon(IconsaxOutline.archive_add),
action: () async {
await addItemToPlaylist(context, [this]);
if (context.mounted) {
context.refreshData();
}
},
label: Text(context.localized.addToPlaylist),
),
if (!exclude.contains(ItemActions.markPlayed))
ItemActionButton(
icon: Icon(IconsaxOutline.eye),
action: () async {
final userData = await ref.read(userProvider.notifier).markAsPlayed(true, id);
onUserDataChanged?.call(userData?.bodyOrThrow);
context.refreshData();
},
label: Text(context.localized.markAsWatched),
),
if (!exclude.contains(ItemActions.markUnplayed))
ItemActionButton(
icon: Icon(IconsaxOutline.eye_slash),
label: Text(context.localized.markAsUnwatched),
action: () async {
final userData = await ref.read(userProvider.notifier).markAsPlayed(false, id);
onUserDataChanged?.call(userData?.bodyOrThrow);
context.refreshData();
},
),
if (!exclude.contains(ItemActions.setFavorite))
ItemActionButton(
icon: Icon(userData.isFavourite ? IconsaxOutline.heart_remove : IconsaxOutline.heart_add),
action: () async {
final newData = await ref.read(userProvider.notifier).setAsFavorite(!userData.isFavourite, id);
onUserDataChanged?.call(newData?.bodyOrThrow);
context.refreshData();
},
label: Text(userData.isFavourite ? context.localized.removeAsFavorite : context.localized.addAsFavorite),
),
...otherActions,
ItemActionDivider(),
if (!exclude.contains(ItemActions.editMetaData) && isAdmin)
ItemActionButton(
icon: Icon(IconsaxOutline.edit),
action: () async {
final newItem = await showEditItemPopup(context, id);
if (newItem != null) {
onItemUpdated?.call(newItem);
}
},
label: Text(context.localized.editMetadata),
),
if (!exclude.contains(ItemActions.refreshMetaData) && isAdmin)
ItemActionButton(
icon: Icon(IconsaxOutline.global_refresh),
action: () async {
showRefreshPopup(context, id, detailedName(context) ?? name);
},
label: Text(context.localized.refreshMetadata),
),
if (!exclude.contains(ItemActions.download) && downloadEnabled) ...{
if (syncedItem == null)
ItemActionButton(
icon: Icon(IconsaxOutline.arrow_down_2),
label: Text(context.localized.sync),
action: () => ref.read(syncProvider.notifier).addSyncItem(context, this),
)
else
ItemActionButton(
icon: IgnorePointer(child: SyncButton(item: this, syncedItem: syncedItem)),
action: () => showSyncItemDetails(context, syncedItem, ref),
label: Text(context.localized.syncDetails),
)
},
if (canDelete == true)
ItemActionButton(
icon: Container(
child: Icon(
IconsaxOutline.trash,
),
),
action: () async {
final response = await showDeleteDialog(context, this, ref);
if (response?.isSuccessful == true) {
onDeleteSuccesFully?.call(this);
if (context.mounted) {
context.refreshData();
}
} else {
fladderSnackbarResponse(context, response);
}
},
label: Text(context.localized.delete),
),
if (!exclude.contains(ItemActions.identify) && identifiable && isAdmin)
ItemActionButton(
icon: Icon(IconsaxOutline.search_normal),
action: () async {
showIdentifyScreen(context, this);
},
label: Text(context.localized.identify),
),
if (!exclude.contains(ItemActions.mediaInfo))
ItemActionButton(
icon: Icon(IconsaxOutline.info_circle),
action: () async {
showInfoScreen(context, this);
},
label: Text("${type.label(context)} ${context.localized.info}"),
),
];
}
}

View file

@ -0,0 +1,340 @@
import 'package:collection/collection.dart';
import 'package:fladder/models/book_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/models/video_stream_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/book_viewer_provider.dart';
import 'package:fladder/providers/items/book_details_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/screens/book_viewer/book_viewer_screen.dart';
import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart';
import 'package:fladder/screens/shared/adaptive_dialog.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/screens/video_player/video_player.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/list_extensions.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
Future<void> _showLoadingIndicator(BuildContext context) async {
return showDialog(
barrierDismissible: kDebugMode,
useRootNavigator: true,
context: context,
builder: (context) => const LoadIndicator(),
);
}
class LoadIndicator extends StatelessWidget {
const LoadIndicator({super.key});
@override
Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(strokeCap: StrokeCap.round),
const SizedBox(width: 70),
Text(
"Loading",
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: 20),
],
),
),
);
}
}
Future<void> _playVideo(
BuildContext context, {
required PlaybackModel? current,
Duration? startPosition,
List<ItemBaseModel>? queue,
required WidgetRef ref,
VoidCallback? onPlayerExit,
}) async {
if (current == null) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
fladderSnackbar(context, title: "No video found to play");
}
return;
}
final loadedCorrectly = await ref.read(videoPlayerProvider.notifier).loadPlaybackItem(
current,
startPosition: startPosition,
);
if (!loadedCorrectly) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
fladderSnackbar(context, title: "An error occurred loading media");
}
return;
}
//Pop loading screen
Navigator.of(context, rootNavigator: true).pop();
ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.fullScreen));
if (context.mounted) {
await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => const VideoPlayer(),
),
);
if (AdaptiveLayout.of(context).isDesktop) {
final fullScreen = await windowManager.isFullScreen();
if (fullScreen) {
await windowManager.setFullScreen(false);
}
}
if (context.mounted) {
context.refreshData();
}
onPlayerExit?.call();
}
}
extension BookBaseModelExtension on BookModel? {
Future<void> play(
BuildContext context,
WidgetRef ref, {
int? currentPage,
AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel>? provider,
BuildContext? parentContext,
}) async {
if (kIsWeb) {
fladderSnackbar(context, title: "Books are not supported on web for now.");
return;
}
if (this == null) {
fladderSnackbar(context, title: "Not a selected book");
return;
}
var newProvider = provider;
if (newProvider == null) {
newProvider = bookDetailsProvider(this?.id ?? "");
await ref.watch(bookDetailsProvider(this?.id ?? "").notifier).fetchDetails(this!);
}
ref.read(bookViewerProvider.notifier).fetchBook(this);
await openBookViewer(
context,
newProvider,
initialPage: currentPage ?? this?.currentPage,
);
parentContext?.refreshData();
if (context.mounted) {
context.refreshData();
}
}
}
extension PhotoAlbumExtension on PhotoAlbumModel? {
Future<void> play(
BuildContext context,
WidgetRef ref, {
int? currentPage,
AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel>? provider,
BuildContext? parentContext,
}) async {
_showLoadingIndicator(context);
final albumModel = this;
if (albumModel == null) return;
final api = ref.read(jellyApiProvider);
final getChildItems = await api.itemsGet(
parentId: albumModel.id,
includeItemTypes: FladderItemType.galleryItem.map((e) => e.dtoKind).toList(),
recursive: true);
final photos = getChildItems.body?.items.whereType<PhotoModel>() ?? [];
Navigator.of(context, rootNavigator: true).pop();
if (photos.isEmpty) {
return;
}
await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => PhotoViewerScreen(
items: photos.toList(),
),
),
);
if (context.mounted) {
context.refreshData();
}
return;
}
}
extension ItemBaseModelExtensions on ItemBaseModel? {
Future<void> play(
BuildContext context,
WidgetRef ref, {
Duration? startPosition,
bool showPlaybackOption = false,
}) async =>
switch (this) {
PhotoAlbumModel album => album.play(context, ref),
BookModel book => book.play(context, ref),
_ => _default(context, this, ref, startPosition: startPosition),
};
Future<void> _default(
BuildContext context,
ItemBaseModel? itemModel,
WidgetRef ref, {
Duration? startPosition,
bool showPlaybackOption = false,
}) async {
if (itemModel == null) return;
_showLoadingIndicator(context);
SyncedItem? syncedItem = ref.read(syncProvider.notifier).getSyncedItem(this);
final options = {
PlaybackType.directStream,
PlaybackType.transcode,
if (syncedItem != null && syncedItem.status == SyncStatus.complete) PlaybackType.offline,
};
PlaybackModel? model;
if (showPlaybackOption) {
final playbackType = await _showPlaybackTypeSelection(
context: context,
options: options,
);
model = switch (playbackType) {
PlaybackType.directStream || PlaybackType.transcode => await ref
.read(playbackModelHelper)
.createServerPlaybackModel(itemModel, playbackType, startPosition: startPosition),
PlaybackType.offline => await ref.read(playbackModelHelper).createOfflinePlaybackModel(itemModel, syncedItem),
null => null
};
} else {
model = (await ref.read(playbackModelHelper).createServerPlaybackModel(itemModel, PlaybackType.directStream)) ??
await ref.read(playbackModelHelper).createOfflinePlaybackModel(itemModel, syncedItem);
}
if (model == null) {
return;
}
await _playVideo(context, startPosition: startPosition, current: model, ref: ref);
}
}
extension ItemBaseModelsBooleans on List<ItemBaseModel> {
Future<void> playLibraryItems(BuildContext context, WidgetRef ref, {bool shuffle = false}) async {
if (isEmpty) return;
_showLoadingIndicator(context);
// Replace all shows/seasons with all episodes
List<List<ItemBaseModel>> newList = await Future.wait(map((element) async {
switch (element.type) {
case FladderItemType.series:
return await ref.read(jellyApiProvider).fetchEpisodeFromShow(seriesId: element.id);
default:
return [element];
}
}));
var expandedList =
newList.expand((element) => element).toList().where((element) => element.playAble).toList().uniqueBy(
(value) => value.id,
);
if (shuffle) {
expandedList.shuffle();
}
PlaybackModel? model = await ref.read(playbackModelHelper).createServerPlaybackModel(
expandedList.firstOrNull,
PlaybackType.directStream,
libraryQueue: expandedList,
);
if (context.mounted) {
await _playVideo(context, ref: ref, queue: expandedList, current: model);
if (context.mounted) {
RefreshState.of(context).refresh();
}
}
}
}
Future<PlaybackType?> _showPlaybackTypeSelection({
required BuildContext context,
required Set<PlaybackType> options,
}) async {
PlaybackType? playbackType;
await showDialogAdaptive(
context: context,
builder: (context) {
return PlaybackDialogue(
options: options,
onClose: (type) {
playbackType = type;
Navigator.of(context).pop();
},
);
},
);
return playbackType;
}
class PlaybackDialogue extends StatelessWidget {
final Set<PlaybackType> options;
final Function(PlaybackType type) onClose;
const PlaybackDialogue({required this.options, required this.onClose, super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16).add(EdgeInsets.only(top: 16, bottom: 8)),
child: Text(
"Playback type",
style: Theme.of(context).textTheme.titleLarge,
),
),
const Divider(),
...options.map((type) => ListTile(
title: Text(type.name),
leading: Icon(type.icon),
onTap: () {
onClose(type);
},
))
],
);
}
}

8
lib/util/jelly_id.dart Normal file
View file

@ -0,0 +1,8 @@
// ignore: depend_on_referenced_packages
import 'package:uuid/uuid.dart';
String get jellyId {
var uuid = Uuid();
var guid = uuid.v4().replaceAll('-', ''); // Remove hyphens
return guid.substring(0, 32); // Take only the first 32 characters
}

View file

@ -0,0 +1,28 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:chopper/chopper.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
extension JellyApiExtension on JellyfinOpenApi {
Future<Response<dynamic>?> itemIdImagesImageTypePost(
ImageType type,
String itemId,
Uint8List data,
) async {
final client = this.client;
final uri = Uri.parse('/Items/$itemId/Images/${type.value}');
final response = await client.send(
Request(
'POST',
uri,
this.client.baseUrl,
body: base64Encode(data),
headers: {
'Content-Type': 'image/*',
},
),
);
return response;
}
}

View file

@ -0,0 +1,96 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class KeyedListView<K, T> extends ConsumerStatefulWidget {
final Map<K, T> map;
final Widget Function(BuildContext context, int index) itemBuilder;
const KeyedListView({required this.map, required this.itemBuilder, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _KeyedListViewState();
}
class _KeyedListViewState extends ConsumerState<KeyedListView> {
final ItemScrollController itemScrollController = ItemScrollController();
final ScrollOffsetController scrollOffsetController = ScrollOffsetController();
final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create();
final ScrollOffsetListener scrollOffsetListener = ScrollOffsetListener.create();
int currentIndex = 0;
@override
void initState() {
super.initState();
itemPositionsListener.itemPositions.addListener(() {
if (currentIndex != itemPositionsListener.itemPositions.value.toList()[0].index) {
setState(() {
currentIndex = itemPositionsListener.itemPositions.value.toList()[0].index;
});
}
});
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Flexible(
child: ScrollablePositionedList.builder(
itemCount: widget.map.length,
itemBuilder: widget.itemBuilder,
itemScrollController: itemScrollController,
scrollOffsetController: scrollOffsetController,
itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener,
),
),
const SizedBox(width: 8),
SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: widget.map.keys.mapIndexed(
(index, e) {
final atPosition = currentIndex == index;
return Container(
decoration: BoxDecoration(
color: atPosition ? Theme.of(context).colorScheme.secondary : Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
height: 28,
width: 28,
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
textStyle: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
foregroundColor: atPosition
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.onSurface.withOpacity(0.35),
),
onPressed: () {
itemScrollController.scrollTo(
index: index,
duration: const Duration(seconds: 1),
opacityAnimationWeights: [20, 20, 60],
curve: Curves.easeOutCubic,
);
},
child: Text(
e,
),
),
);
},
).toList(),
),
),
],
);
}
}

View file

@ -0,0 +1,92 @@
import 'package:collection/collection.dart';
extension ListExtensions<T> on List<T> {
List<T> replace(T entry) {
var tempList = toList();
final index = indexOf(entry);
tempList.removeAt(index);
tempList.insert(index, entry);
return tempList;
}
List<T> toggle(T entry) {
var tempList = toList();
if (contains(entry)) {
return tempList..remove(entry);
} else {
return tempList..add(entry);
}
}
bool containsAny(Iterable<T> entries) {
for (var value in entries) {
if (contains(value)) {
return true;
}
}
return false;
}
List<T> toggleUnique(T entry) => toggle(entry).toSet().toList();
List<T> random() {
List<T> tempList = this;
tempList.shuffle();
return tempList;
}
List<T> uniqueBy(dynamic Function(T value) keySelector) {
final Map<dynamic, T> uniqueMap = {};
for (var item in this) {
final key = keySelector(item);
if (!uniqueMap.containsKey(key)) {
uniqueMap[key] = item;
}
}
return uniqueMap.values.toList();
}
Iterable<List<T>> chunk(int size) sync* {
if (size <= 0) {
throw ArgumentError('Chunk size must be greater than zero.');
}
final iterator = this.iterator;
while (iterator.moveNext()) {
final chunk = <T>[];
for (var i = 0; i < size; i++) {
if (!iterator.moveNext()) {
break;
}
chunk.add(iterator.current);
}
yield chunk;
}
}
T? nextOrNull(T item) {
int indexOf = this.indexOf(item);
if (indexOf + 1 >= length) return null;
return elementAtOrNull(indexOf + 1);
}
T? previousOrNull(T item) {
int indexOf = this.indexOf(item);
if (indexOf - 1 < 0) return null;
return elementAtOrNull(indexOf - 1);
}
T? nextWhereOrNull(bool Function(T element) test) {
final indexOf = indexWhere((element) => test(element));
if (indexOf + 1 < length) return null;
return elementAtOrNull(indexOf + 1);
}
T? previousWhereOrNull(bool Function(T element) test) {
final indexOf = indexWhere((element) => test(element));
if (indexOf - 1 < length) return null;
return elementAtOrNull(indexOf - 1);
}
}

View file

@ -0,0 +1,42 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
extension ListExtensions on List<Widget> {
addInBetween(Widget widget) {
return mapIndexed(
(index, element) {
if (element != last) {
return [element, widget];
} else {
return [element];
}
},
).expand((element) => element).toList();
}
addPadding(EdgeInsets padding) {
return map((e) {
if (e is Expanded || e is Spacer || e is Flexible) return e;
return Padding(
padding: padding.copyWith(
top: e == first ? 0 : null,
left: e == first ? 0 : null,
right: e == last ? 0 : null,
bottom: e == last ? 0 : null,
),
child: e,
);
}).toList();
}
addSize({double? width, double? height}) {
return map((e) {
if (e is Expanded || e is Spacer || e is Flexible) return e;
return SizedBox(
width: width,
height: height,
child: e,
);
}).toList();
}
}

View file

@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
extension LocalExtensions on Locale {
String label() {
return switch (languageCode) {
"nl" => "Nederlands",
"zh" => "简体中文",
"es" => "Español",
"fr" => "Français",
"ja" => "日本語 (にほんご)",
"en" || _ => "English",
};
}
}

View file

@ -0,0 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
extension BuildContextExtension on BuildContext {
AppLocalizations get localized => AppLocalizations.of(this);
}

View file

@ -0,0 +1,62 @@
extension MapExtensions<T> on Map<T, bool> {
Map<T, bool> toggleKey(T wantedKey) {
return map((key, value) => MapEntry(key, wantedKey == key ? !value : value));
}
Map<T, bool> setKey(T? wantedKey, bool enable) {
return map((key, value) => MapEntry(key, wantedKey == key ? enable : value));
}
Map<T, bool> setKeys(Iterable<T?> wantedKey, bool enable) {
var tempMap = map((key, value) => MapEntry(key, value));
for (var element in wantedKey) {
tempMap = tempMap.setKey(element, enable);
}
return tempMap;
}
Map<T, bool> setAll(bool toggle) {
return map((key, value) => MapEntry(key, toggle));
}
List<T> get included {
return entries.where((entry) => entry.value).map((entry) => entry.key).toList();
}
List<T> get notIncluded {
return entries.where((entry) => !entry.value).map((entry) => entry.key).toList();
}
Map<T, bool> get enabledFirst {
final enabled = Map<T, bool>.from(this)..removeWhere((key, value) => !value);
final disabled = Map<T, bool>.from(this)..removeWhere((key, value) => value);
return enabled..addAll(disabled);
}
bool get hasEnabled => values.any((element) => element == true);
Map<T, bool> replaceMap(Map<T, bool> oldMap) {
Map<T, bool> result = {};
forEach((key, value) {
result[key] = oldMap[key] ?? false;
});
return result;
}
}
extension MapExtensionsGeneric<K, V> on Map<K, V> {
Map<K, V> setKey(K? wantedKey, V newValue) {
return map((key, value) => MapEntry(key, key == wantedKey ? newValue : value));
}
Map<K, V> setKeys(Iterable<K?> wantedKey, V value) {
var tempMap = map((key, value) => MapEntry(key, value));
for (var element in wantedKey) {
tempMap = tempMap.setKey(element, value);
}
return tempMap;
}
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class MouseParking extends ConsumerStatefulWidget {
final Function(PointerEvent)? onHover;
const MouseParking({this.onHover, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _MouseParkingState();
}
class _MouseParkingState extends ConsumerState<MouseParking> {
bool parked = false;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 100,
height: 100,
child: MouseRegion(
onEnter: (event) => setState(() => parked = true),
onExit: (event) => setState(() => parked = false),
onHover: widget.onHover,
cursor: SystemMouseCursors.none,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(topLeft: Radius.circular(20)),
color: parked ? Theme.of(context).colorScheme.primary.withOpacity(0.5) : Colors.black12,
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mouse_rounded),
Icon(Icons.local_parking),
],
),
),
),
);
}
}

View file

@ -0,0 +1,14 @@
import 'dart:math';
extension RangeNum on num {
bool isInRange(num index, num range) {
return index - range < this && this < index + range;
}
}
extension DoubleExtension on double {
double roundTo(int places) {
num mod = pow(10.0, places);
return ((this * mod).round().toDouble() / mod);
}
}

View file

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
Future<void> openOptionDialogue<T>(
BuildContext context, {
required String label,
required List<T> items,
bool isNullable = false,
required Widget Function(T? type) itemBuilder,
}) {
return showDialog(
context: context,
builder: (context) {
return AlertDialog.adaptive(
title: Text(label),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
shrinkWrap: true,
children: [
if (isNullable) itemBuilder(null),
...items.map(
(e) => itemBuilder(e),
)
],
),
),
);
},
);
}

View file

@ -0,0 +1,16 @@
import 'package:collection/collection.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:media_kit/media_kit.dart';
import 'dart:io' show Platform;
extension PlayerExtensions on Player {
Future<void> addSubtitles(List<SubStreamModel> subtitles) async {
final separator = Platform.isWindows ? ";" : ":";
await (platform as NativePlayer).setProperty(
"sub-files",
subtitles
.mapIndexed((index, e) => "${Platform.isWindows ? e.url : e.url?.replaceFirst(":", "\\:")}@${e.displayTitle}")
.join(separator),
);
}
}

View file

@ -0,0 +1,6 @@
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:media_kit/media_kit.dart';
extension PlayerExtensions on Player {
Future<void> addSubtitles(List<SubStreamModel> subtitles) async {}
}

View file

@ -0,0 +1,8 @@
class PosterDefaults {
final double size;
final double ratio;
double get gridRatio => size * ratio;
const PosterDefaults({required this.size, required this.ratio});
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
class RefreshState extends InheritedWidget {
final GlobalKey<RefreshIndicatorState> refreshKey;
final bool refreshAble;
const RefreshState({
super.key,
required this.refreshKey,
this.refreshAble = true,
required super.child,
});
Future<void> refresh() async {
if (refreshAble) return await refreshKey.currentState?.show();
return;
}
static RefreshState? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<RefreshState>();
}
static RefreshState of(BuildContext context) {
final RefreshState? result = maybeOf(context);
return result!;
}
@override
bool updateShouldNotify(RefreshState oldWidget) {
return refreshKey != oldWidget.refreshKey;
}
}
extension RefreshContextExtension on BuildContext {
Future<void> refreshData() async {
//Small delay to fix server not updating response based on successful query
await Future.delayed(const Duration(milliseconds: 250));
await RefreshState.maybeOf(this)?.refresh();
}
}

View file

@ -0,0 +1,174 @@
import 'dart:developer';
import 'package:fladder/screens/shared/outlined_text_field.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
String timePickerString(BuildContext context, Duration? duration) {
if (duration == null) return context.localized.never;
if (duration.inSeconds <= 0) return context.localized.immediately;
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
final minutesString = "$minutes ${context.localized.minutes(minutes)}";
final secondsString = "$seconds ${context.localized.seconds(seconds)}";
if (minutes > 0 && seconds > 0) {
return context.localized.timeAndAnnotation(minutesString, secondsString);
} else if (minutes > 0) {
return minutesString;
} else {
return secondsString;
}
}
Future<Duration?> showSimpleDurationPicker({
required BuildContext context,
required Duration initialValue,
bool showNever = true,
}) async {
Duration? duration;
await showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: Text(context.localized.selectTime),
content: SimpleDurationPicker(
initialValue: initialValue,
onChanged: (value) {
duration = value;
Navigator.of(context).pop();
},
showNever: showNever,
),
),
);
return duration;
}
class SimpleDurationPicker extends ConsumerWidget {
final Duration initialValue;
final ValueChanged<Duration?> onChanged;
final bool showNever;
const SimpleDurationPicker({required this.initialValue, required this.onChanged, required this.showNever, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final minuteTextController = TextEditingController(text: initialValue.inMinutes.toString().padLeft(2, '0'));
final secondsTextController = TextEditingController(text: (initialValue.inSeconds % 60).toString().padLeft(2, '0'));
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedTextField(
controller: minuteTextController,
style: Theme.of(context).textTheme.displaySmall,
keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
borderWidth: 0,
textInputAction: TextInputAction.done,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
context.localized.minutes(0),
style: Theme.of(context).textTheme.labelLarge,
)
],
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
':',
style: Theme.of(context).textTheme.displayLarge,
),
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedTextField(
controller: secondsTextController,
style: Theme.of(context).textTheme.displaySmall,
keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
borderWidth: 0,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
try {
final parsedValue = int.parse(value);
if (parsedValue >= 60) {
secondsTextController.text = (parsedValue % 60).toString().padLeft(2, '0');
minuteTextController.text = (int.parse(minuteTextController.text) + parsedValue / 60)
.floor()
.toString()
.padLeft(2, '0');
}
onChanged(
Duration(
minutes: int.tryParse(minuteTextController.text) ?? 0,
seconds: int.tryParse(secondsTextController.text) ?? 0,
),
);
} catch (e) {
log(e.toString());
}
},
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
context.localized.seconds(0),
style: Theme.of(context).textTheme.labelLarge,
)
],
),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (showNever) ...{
TextButton(
onPressed: () => onChanged(null),
child: Text(context.localized.never),
),
const Spacer(),
},
TextButton(
onPressed: () => onChanged(initialValue),
child: Text(context.localized.cancel),
),
if (!showNever) const Spacer() else const SizedBox(width: 6),
FilledButton(
onPressed: () => onChanged(
Duration(
minutes: int.tryParse(minuteTextController.text) ?? 0,
seconds: int.tryParse(secondsTextController.text) ?? 0,
),
),
child: Text(context.localized.set),
),
],
)
],
);
}
}

View file

@ -0,0 +1,22 @@
// ignore_for_file: constant_identifier_names
extension IntExtension on int? {
String? get byteFormat {
final bytes = this;
if (bytes == null) return null;
if (bytes == 0) return "- bytes";
const int KB = 1024;
const int MB = KB * KB;
const int GB = MB * KB;
if (bytes >= GB) {
return '${(bytes / GB).toStringAsFixed(2)} GB';
} else if (bytes >= MB) {
return '${(bytes / MB).toStringAsFixed(2)} MB';
} else if (bytes >= KB) {
return '${(bytes / KB).toStringAsFixed(2)} KB';
} else {
return '$bytes Bytes';
}
}
}

View file

@ -0,0 +1,25 @@
import 'package:fladder/util/adaptive_layout.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class DefautlSliverBottomPadding extends StatelessWidget {
const DefautlSliverBottomPadding({super.key});
@override
Widget build(BuildContext context) {
return (AdaptiveLayout.of(context).isDesktop || kIsWeb)
? const SliverToBoxAdapter()
: SliverPadding(padding: EdgeInsets.only(bottom: 85 + MediaQuery.of(context).padding.bottom));
}
}
class DefaultSliverTopBadding extends StatelessWidget {
const DefaultSliverTopBadding({super.key});
@override
Widget build(BuildContext context) {
return (AdaptiveLayout.of(context).isDesktop || kIsWeb)
? const SliverPadding(padding: EdgeInsets.only(top: 35))
: SliverPadding(padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top));
}
}

View file

@ -0,0 +1,49 @@
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class StickyHeaderText extends ConsumerStatefulWidget {
final String label;
final Function()? onClick;
const StickyHeaderText({required this.label, this.onClick, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => StickyHeaderTextState();
}
class StickyHeaderTextState extends ConsumerState<StickyHeaderText> {
late Color color = Theme.of(context).colorScheme.onSurface;
@override
Widget build(BuildContext context) {
return FlatButton(
onTap: widget.onClick,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
widget.label,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (widget.onClick != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8).copyWith(bottom: 4),
child: Icon(
IconsaxOutline.arrow_right_3,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
],
),
),
);
}
}

View file

@ -0,0 +1,38 @@
import 'dart:async';
class StreamValue<T> {
final T _initialValue;
T _latestValue;
final StreamController<T> _controller;
bool _hasInitialValue = true;
StreamValue(T initialValue)
: _initialValue = initialValue,
_latestValue = initialValue,
_controller = StreamController<T>.broadcast();
Stream<T> get stream => _controller.stream;
void add(T value) {
_latestValue = value;
_hasInitialValue = false;
_controller.add(value);
}
void addError(Object error, [StackTrace? stackTrace]) {
_controller.addError(error, stackTrace);
}
void listen(void Function(T) onData, {Function? onError, void Function()? onDone, bool? cancelOnError}) {
if (_hasInitialValue) {
onData(_initialValue);
} else {
onData(_latestValue);
}
_controller.stream.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError);
}
void close() {
_controller.close();
}
}

View file

@ -0,0 +1,68 @@
import 'package:collection/collection.dart';
import 'package:fladder/models/items/item_shared_models.dart';
extension StringExtensions on String {
String capitalize() {
if (isEmpty) return '';
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
}
String rtrim([String? chars]) {
var pattern = chars != null ? RegExp('[$chars]+\$') : RegExp(r'\s+$');
return replaceAll(pattern, '');
}
String maxLength({int limitTo = 75}) {
if (length > limitTo) {
return "${substring(0, limitTo.clamp(0, length))}...";
} else {
return substring(0, limitTo.clamp(0, length));
}
}
String getInitials({int limitTo = 2}) {
if (isEmpty) return "";
var buffer = StringBuffer();
var split = this.split(' ');
for (var i = 0; i < (limitTo.clamp(0, split.length)); i++) {
buffer.write(split[i][0]);
}
return buffer.toString();
}
String toUpperCaseSplit() {
String result = '';
for (int i = 0; i < length; i++) {
if (i == 0) {
result += this[i].toUpperCase();
} else if ((i > 0 && this[i].toUpperCase() == this[i])) {
result += ' ${this[i].toUpperCase()}';
} else {
result += this[i];
}
}
return result;
}
}
extension ListExtensions on List<String> {
String flatString({int count = 3}) {
return take(3).map((e) => e.capitalize()).join(" | ");
}
}
extension GenreExtensions on List<GenreItems> {
String flatString({int count = 3}) {
return take(3).map((e) => e.name.capitalize()).join(" | ");
}
}
extension StringListExtension on List<String?> {
String get detailsTitle {
return whereNotNull().join("");
}
}

View file

@ -0,0 +1,6 @@
import 'package:flutter/material.dart';
extension ThemeExtensions on BuildContext {
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get textTheme => Theme.of(this).textTheme;
}

View file

@ -0,0 +1,12 @@
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
extension ThemeModeExtension on ThemeMode {
String label(BuildContext context) {
return switch (this) {
ThemeMode.light => context.localized.themeModeLight,
ThemeMode.dark => context.localized.themeModeDark,
ThemeMode.system => context.localized.themeModeSystem,
};
}
}

25
lib/util/themes_data.dart Normal file
View file

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
class ThemesData extends InheritedWidget {
const ThemesData({
super.key,
required this.light,
required this.dark,
required super.child,
});
final ThemeData light;
final ThemeData dark;
static ThemesData? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ThemesData>();
}
static ThemesData of(BuildContext context) {
final ThemesData? result = maybeOf(context);
return result!;
}
@override
bool updateShouldNotify(ThemesData oldWidget) => light != oldWidget.light || dark != oldWidget.dark;
}

20
lib/util/throttler.dart Normal file
View file

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
class Throttler {
final Duration duration;
int? lastActionTime;
Throttler({required this.duration});
void run(VoidCallback action) {
if (lastActionTime == null) {
lastActionTime = DateTime.now().millisecondsSinceEpoch;
action();
} else {
if (DateTime.now().millisecondsSinceEpoch - lastActionTime! > (duration.inMilliseconds)) {
lastActionTime = DateTime.now().millisecondsSinceEpoch;
action();
}
}
}
}

View file

@ -0,0 +1,45 @@
import 'package:media_kit/media_kit.dart';
import 'package:validators/validators.dart';
import 'string_extensions.dart';
extension SubtitleExtension on SubtitleTrack {
String get cleanName {
final names = {
id,
title,
};
return names
.where((element) => element != null)
.map((e) {
if (e == null) return e;
if (isNumeric(e)) return '';
if (e == "no") {
return "Off";
}
return e.capitalize();
})
.where((element) => element != null && element.isNotEmpty)
.join(" - ");
}
}
extension AudioTrackExtension on AudioTrack {
String get cleanName {
final names = {
id,
title,
};
return names
.where((element) => element != null)
.map((e) {
if (e == null) return e;
if (isNumeric(e)) return '';
if (e == "no") {
return "Off";
}
return e.capitalize();
})
.where((element) => element != null && element.isNotEmpty)
.join(" - ");
}
}

View file

@ -0,0 +1,119 @@
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto;
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
enum Resolution {
sd("SD"),
hd("HD"),
udh("4K");
const Resolution(this.value);
final String value;
Widget icon(
BuildContext context,
Function()? onTap,
) {
return DefaultVideoInformationBox(
onTap: onTap,
child: Text(
value,
),
);
}
static Resolution? fromVideoStream(VideoStreamModel? model) {
if (model == null) return null;
return Resolution.fromSize(model.width, model.height);
}
static Resolution? fromSize(int? width, int? height) {
if (width == null || height == null) return null;
if (height <= 1080 && width <= 1920) {
return Resolution.hd;
} else if (height <= 2160 && width <= 3840) {
return Resolution.udh;
} else {
return Resolution.sd;
}
}
}
enum DisplayProfile {
sdr("SDR"),
hdr("HDR"),
hdr10("HDR10"),
dolbyVision("Dolby Vision"),
dolbyVisionHdr10("DoVi/HDR10"),
hlg("HLG");
const DisplayProfile(this.value);
final String value;
Widget icon(
BuildContext context,
Function()? onTap,
) {
return DefaultVideoInformationBox(
onTap: onTap,
child: Text(
value,
),
);
}
static DisplayProfile? fromStreams(List<MediaStream>? mediaStreams) {
final videoStream = (mediaStreams?.firstWhereOrNull((element) => element.type == dto.MediaStreamType.video) ??
mediaStreams?.firstOrNull);
if (videoStream == null) return null;
return DisplayProfile.fromVideoStream(VideoStreamModel.fromMediaStream(videoStream));
}
static DisplayProfile? fromVideoStreams(List<VideoStreamModel>? mediaStreams) {
final videoStream = mediaStreams?.firstWhereOrNull((element) => element.isDefault) ?? mediaStreams?.firstOrNull;
if (videoStream == null) return null;
return DisplayProfile.fromVideoStream(videoStream);
}
static DisplayProfile fromVideoStream(VideoStreamModel stream) {
switch (stream.videoRangeType) {
case null:
case dto.VideoRangeType.hlg:
return DisplayProfile.hlg;
case dto.VideoRangeType.hdr10:
return DisplayProfile.hdr10;
default:
return DisplayProfile.sdr;
}
}
}
class DefaultVideoInformationBox extends ConsumerWidget {
final Widget child;
final Function()? onTap;
const DefaultVideoInformationBox({required this.child, this.onTap, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
child: FlatButton(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Material(
type: MaterialType.button,
textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
color: Colors.transparent,
child: Center(child: child),
),
),
),
);
}
}

View file

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
extension WidgetExtensions on Widget {
Widget padding(EdgeInsets insets) {
return Padding(
padding: insets,
child: this,
);
}
Widget setKey(Key? key) {
return Center(
key: key,
child: this,
);
}
Widget addHeroTag(String? tag) {
if (tag != null) {
return Hero(tag: tag, child: this);
} else {
return this;
}
}
Widget addVisiblity(bool visible) {
return AnimatedOpacity(duration: const Duration(milliseconds: 250), opacity: visible ? 1 : 0, child: this);
}
}