mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 21:48:14 -08:00
Init repo
This commit is contained in:
commit
764b6034e3
566 changed files with 212335 additions and 0 deletions
22
lib/util/absorb_events.dart
Normal file
22
lib/util/absorb_events.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
193
lib/util/adaptive_layout.dart
Normal file
193
lib/util/adaptive_layout.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/util/application_info.dart
Normal file
38
lib/util/application_info.dart
Normal 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)';
|
||||
}
|
||||
36
lib/util/auth_service.dart
Normal file
36
lib/util/auth_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
16
lib/util/box_fit_extension.dart
Normal file
16
lib/util/box_fit_extension.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
81
lib/util/custom_color_themes.dart
Normal file
81
lib/util/custom_color_themes.dart
Normal 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
14
lib/util/debouncer.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
28
lib/util/disable_keypad_focus.dart
Normal file
28
lib/util/disable_keypad_focus.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
lib/util/duration_extensions.dart
Normal file
17
lib/util/duration_extensions.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
44
lib/util/fab_extended_anim.dart
Normal file
44
lib/util/fab_extended_anim.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
63
lib/util/fladder_image.dart
Normal file
63
lib/util/fladder_image.dart
Normal 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
14
lib/util/grouping.dart
Normal 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;
|
||||
}
|
||||
11
lib/util/header_generate.dart
Normal file
11
lib/util/header_generate.dart
Normal 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}"',
|
||||
};
|
||||
}
|
||||
28
lib/util/humanize_duration.dart
Normal file
28
lib/util/humanize_duration.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
250
lib/util/item_base_model/item_base_model_extensions.dart
Normal file
250
lib/util/item_base_model/item_base_model_extensions.dart
Normal 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}"),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
340
lib/util/item_base_model/play_item_helpers.dart
Normal file
340
lib/util/item_base_model/play_item_helpers.dart
Normal 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
8
lib/util/jelly_id.dart
Normal 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
|
||||
}
|
||||
28
lib/util/jellyfin_extension.dart
Normal file
28
lib/util/jellyfin_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
96
lib/util/keyed_list_view.dart
Normal file
96
lib/util/keyed_list_view.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
92
lib/util/list_extensions.dart
Normal file
92
lib/util/list_extensions.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
42
lib/util/list_padding.dart
Normal file
42
lib/util/list_padding.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
14
lib/util/local_extension.dart
Normal file
14
lib/util/local_extension.dart
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
6
lib/util/localization_helper.dart
Normal file
6
lib/util/localization_helper.dart
Normal 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);
|
||||
}
|
||||
62
lib/util/map_bool_helper.dart
Normal file
62
lib/util/map_bool_helper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
40
lib/util/mouse_parking.dart
Normal file
40
lib/util/mouse_parking.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
14
lib/util/num_extension.dart
Normal file
14
lib/util/num_extension.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
31
lib/util/option_dialogue.dart
Normal file
31
lib/util/option_dialogue.dart
Normal 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
16
lib/util/player_extensions.dart
Normal file
16
lib/util/player_extensions.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
6
lib/util/player_extensions_web.dart
Normal file
6
lib/util/player_extensions_web.dart
Normal 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 {}
|
||||
}
|
||||
8
lib/util/poster_defaults.dart
Normal file
8
lib/util/poster_defaults.dart
Normal 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});
|
||||
}
|
||||
40
lib/util/refresh_state.dart
Normal file
40
lib/util/refresh_state.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
174
lib/util/simple_duration_picker.dart
Normal file
174
lib/util/simple_duration_picker.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/util/size_formatting.dart
Normal file
22
lib/util/size_formatting.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
25
lib/util/sliver_list_padding.dart
Normal file
25
lib/util/sliver_list_padding.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
49
lib/util/sticky_header_text.dart
Normal file
49
lib/util/sticky_header_text.dart
Normal 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,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/util/stream_value.dart
Normal file
38
lib/util/stream_value.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
68
lib/util/string_extensions.dart
Normal file
68
lib/util/string_extensions.dart
Normal 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(" ● ");
|
||||
}
|
||||
}
|
||||
6
lib/util/theme_extensions.dart
Normal file
6
lib/util/theme_extensions.dart
Normal 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;
|
||||
}
|
||||
12
lib/util/theme_mode_extension.dart
Normal file
12
lib/util/theme_mode_extension.dart
Normal 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
25
lib/util/themes_data.dart
Normal 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
20
lib/util/throttler.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
lib/util/track_extensions.dart
Normal file
45
lib/util/track_extensions.dart
Normal 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(" - ");
|
||||
}
|
||||
}
|
||||
119
lib/util/video_properties.dart
Normal file
119
lib/util/video_properties.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/util/widget_extensions.dart
Normal file
29
lib/util/widget_extensions.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue