mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 21:48:14 -08:00
feat: Android TV support (#503)
Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
parent
7ab8c015b9
commit
c299492d6d
168 changed files with 12019 additions and 3073 deletions
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/settings/home_settings_model.dart';
|
||||
import 'package:fladder/providers/arguments_provider.dart';
|
||||
import 'package:fladder/providers/settings/home_settings_provider.dart';
|
||||
import 'package:fladder/screens/home_screen.dart';
|
||||
import 'package:fladder/util/adaptive_layout/adaptive_layout_model.dart';
|
||||
|
|
@ -15,12 +16,14 @@ import 'package:fladder/util/resolution_checker.dart';
|
|||
enum InputDevice {
|
||||
touch,
|
||||
pointer,
|
||||
dpad,
|
||||
}
|
||||
|
||||
enum ViewSize {
|
||||
phone,
|
||||
tablet,
|
||||
desktop;
|
||||
desktop,
|
||||
television;
|
||||
|
||||
const ViewSize();
|
||||
|
||||
|
|
@ -28,6 +31,7 @@ enum ViewSize {
|
|||
ViewSize.phone => context.localized.phone,
|
||||
ViewSize.tablet => context.localized.tablet,
|
||||
ViewSize.desktop => context.localized.desktop,
|
||||
ViewSize.television => context.localized.television,
|
||||
};
|
||||
|
||||
bool operator >(ViewSize other) => index > other.index;
|
||||
|
|
@ -174,12 +178,20 @@ class _AdaptiveLayoutBuilderState extends ConsumerState<AdaptiveLayoutBuilder> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final acceptedLayouts = ref.watch(homeSettingsProvider.select((value) => value.screenLayouts));
|
||||
final acceptedViewSizes = ref.watch(homeSettingsProvider.select((value) => value.layoutStates));
|
||||
final arguments = ref.watch(argumentsStateProvider);
|
||||
final htpcMode = arguments.htpcMode;
|
||||
final acceptedLayouts =
|
||||
htpcMode ? {LayoutMode.dual} : ref.watch(homeSettingsProvider.select((value) => value.screenLayouts));
|
||||
final acceptedViewSizes =
|
||||
htpcMode ? {ViewSize.television} : ref.watch(homeSettingsProvider.select((value) => value.layoutStates));
|
||||
|
||||
final selectedViewSize = selectAvailableOrSmaller<ViewSize>(viewSize, acceptedViewSizes, ViewSize.values);
|
||||
final selectedLayoutMode = selectAvailableOrSmaller<LayoutMode>(layoutMode, acceptedLayouts, LayoutMode.values);
|
||||
final input = (isDesktop || kIsWeb) ? InputDevice.pointer : InputDevice.touch;
|
||||
final input = htpcMode
|
||||
? InputDevice.dpad
|
||||
: (isDesktop || kIsWeb)
|
||||
? InputDevice.pointer
|
||||
: InputDevice.touch;
|
||||
|
||||
final posterDefaults = const PosterDefaults(size: 350, ratio: 0.55);
|
||||
|
||||
|
|
@ -195,8 +207,10 @@ class _AdaptiveLayoutBuilderState extends ConsumerState<AdaptiveLayoutBuilder> {
|
|||
posterDefaults: posterDefaults,
|
||||
);
|
||||
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
data: mediaQuery.copyWith(
|
||||
padding: isDesktop || kIsWeb ? const EdgeInsets.only(top: defaultTitleBarHeight, bottom: 16) : null,
|
||||
viewPadding: isDesktop || kIsWeb ? const EdgeInsets.only(top: defaultTitleBarHeight, bottom: 16) : null,
|
||||
),
|
||||
|
|
@ -211,9 +225,14 @@ class _AdaptiveLayoutBuilderState extends ConsumerState<AdaptiveLayoutBuilder> {
|
|||
posterDefaults: posterDefaults,
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) => ResolutionChecker(
|
||||
child: widget.adaptiveLayout == null ? DebugBanner(child: widget.child(context)) : widget.child(context),
|
||||
),
|
||||
builder: (context) => isDesktop
|
||||
? ResolutionChecker(
|
||||
child:
|
||||
widget.adaptiveLayout == null ? DebugBanner(child: widget.child(context)) : widget.child(context),
|
||||
)
|
||||
: widget.adaptiveLayout == null
|
||||
? DebugBanner(child: widget.child(context))
|
||||
: widget.child(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ class CustomCacheManager {
|
|||
Config(
|
||||
key,
|
||||
stalePeriod: const Duration(days: 3),
|
||||
maxNrOfCacheObjects: 250,
|
||||
maxNrOfCacheObjects: 256,
|
||||
fileService: HttpFileService(),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ class FladderImage extends ConsumerWidget {
|
|||
final AlignmentGeometry? alignment;
|
||||
final bool disableBlur;
|
||||
final bool blurOnly;
|
||||
final int? decodeHeight;
|
||||
const FladderImage({
|
||||
required this.image,
|
||||
this.frameBuilder,
|
||||
|
|
@ -29,6 +30,7 @@ class FladderImage extends ConsumerWidget {
|
|||
this.alignment,
|
||||
this.disableBlur = false,
|
||||
this.blurOnly = false,
|
||||
this.decodeHeight = 400,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -48,20 +50,23 @@ class FladderImage extends ConsumerWidget {
|
|||
Image(
|
||||
image: BlurHashImage(
|
||||
newImage.hash,
|
||||
decodingHeight: 24,
|
||||
decodingWidth: 24,
|
||||
decodingHeight: 16,
|
||||
decodingWidth: 16,
|
||||
),
|
||||
fit: blurFit ?? fit,
|
||||
height: 16,
|
||||
),
|
||||
if (!blurOnly && imageProvider != null)
|
||||
FadeInImage(
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
fit: fit,
|
||||
placeholderFit: fit,
|
||||
excludeFromSemantics: true,
|
||||
alignment: alignment ?? Alignment.center,
|
||||
imageErrorBuilder: imageErrorBuilder,
|
||||
image: imageProvider,
|
||||
image: ResizeImage(
|
||||
imageProvider,
|
||||
height: decodeHeight,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
|
|
|
|||
188
lib/util/focus_provider.dart
Normal file
188
lib/util/focus_provider.dart
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart';
|
||||
|
||||
final acceptKeys = {
|
||||
LogicalKeyboardKey.space,
|
||||
LogicalKeyboardKey.enter,
|
||||
LogicalKeyboardKey.accept,
|
||||
LogicalKeyboardKey.select,
|
||||
LogicalKeyboardKey.gameButtonA,
|
||||
};
|
||||
|
||||
class FocusProvider extends InheritedWidget {
|
||||
final bool hasFocus;
|
||||
final bool autoFocus;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
const FocusProvider({
|
||||
super.key,
|
||||
this.hasFocus = false,
|
||||
this.autoFocus = false,
|
||||
this.focusNode,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static bool of(BuildContext context) {
|
||||
final widget = context.dependOnInheritedWidgetOfExactType<FocusProvider>();
|
||||
return widget?.hasFocus ?? false;
|
||||
}
|
||||
|
||||
static bool autoFocusOf(BuildContext context) {
|
||||
final widget = context.dependOnInheritedWidgetOfExactType<FocusProvider>();
|
||||
return widget?.autoFocus ?? false;
|
||||
}
|
||||
|
||||
static FocusNode? focusNodeOf(BuildContext context) {
|
||||
final widget = context.dependOnInheritedWidgetOfExactType<FocusProvider>();
|
||||
return widget?.focusNode;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(FocusProvider oldWidget) {
|
||||
return oldWidget.hasFocus != hasFocus;
|
||||
}
|
||||
}
|
||||
|
||||
class FocusButton extends StatefulWidget {
|
||||
final Widget? child;
|
||||
final List<Widget> overlays;
|
||||
final Function()? onTap;
|
||||
final Function()? onLongPress;
|
||||
final Function(TapDownDetails)? onSecondaryTapDown;
|
||||
final bool darkOverlay;
|
||||
final Function(bool focus)? onFocusChanged;
|
||||
|
||||
const FocusButton({
|
||||
this.child,
|
||||
this.overlays = const [],
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onSecondaryTapDown,
|
||||
this.darkOverlay = true,
|
||||
this.onFocusChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FocusButton> createState() => FocusButtonState();
|
||||
}
|
||||
|
||||
class FocusButtonState extends State<FocusButton> {
|
||||
bool onHover = false;
|
||||
Timer? _longPressTimer;
|
||||
bool _longPressTriggered = false;
|
||||
bool _keyDownActive = false;
|
||||
|
||||
static const Duration _kLongPressTimeout = Duration(milliseconds: 500);
|
||||
|
||||
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
|
||||
if (!node.hasFocus) return KeyEventResult.ignored;
|
||||
|
||||
if (acceptKeys.contains(event.logicalKey)) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (_keyDownActive) return KeyEventResult.ignored;
|
||||
_keyDownActive = true;
|
||||
_startLongPressTimer();
|
||||
} else if (event is KeyUpEvent) {
|
||||
if (!_keyDownActive) return KeyEventResult.ignored;
|
||||
if (_longPressTriggered) {
|
||||
_resetKeyState();
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
_cancelLongPressTimer();
|
||||
_keyDownActive = false;
|
||||
widget.onTap?.call();
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _startLongPressTimer() {
|
||||
_longPressTriggered = false;
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = Timer(_kLongPressTimeout, () {
|
||||
_longPressTriggered = true;
|
||||
widget.onLongPress?.call();
|
||||
_resetKeyState();
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelLongPressTimer() {
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = null;
|
||||
}
|
||||
|
||||
void _resetKeyState() {
|
||||
_cancelLongPressTimer();
|
||||
_keyDownActive = false;
|
||||
_longPressTriggered = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resetKeyState();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final focusNode = FocusProvider.focusNodeOf(context);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (event) => setState(() => onHover = true),
|
||||
onExit: (event) => setState(() => onHover = false),
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
child: Focus(
|
||||
focusNode: focusNode,
|
||||
onFocusChange: (value) {
|
||||
widget.onFocusChanged?.call(value);
|
||||
if (value) {
|
||||
lastMainFocus = focusNode;
|
||||
}
|
||||
setState(() {
|
||||
onHover = value;
|
||||
});
|
||||
},
|
||||
onKeyEvent: _handleKey,
|
||||
child: ExcludeFocus(
|
||||
child: FlatButton(
|
||||
onTap: widget.onTap,
|
||||
onSecondaryTapDown: widget.onSecondaryTapDown,
|
||||
onLongPress: widget.onLongPress,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: FladderTheme.smallShape.borderRadius,
|
||||
child: widget.child,
|
||||
),
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
opacity: onHover ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 125),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.darkOverlay ? Colors.black.withValues(alpha: 0.35) : Colors.transparent,
|
||||
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primaryFixed),
|
||||
borderRadius: FladderTheme.smallShape.borderRadius,
|
||||
),
|
||||
child: Stack(
|
||||
children: widget.overlays,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ class _InputHandlerState<T> extends ConsumerState<InputHandler<T>> {
|
|||
return Focus(
|
||||
autofocus: widget.autoFocus,
|
||||
focusNode: focusNode,
|
||||
skipTraversal: true,
|
||||
onFocusChange: (value) {
|
||||
if (!focusNode.hasFocus && widget.autoFocus) {
|
||||
focusNode.requestFocus();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import 'package:fladder/providers/video_player_provider.dart';
|
|||
import 'package:fladder/routes/auto_router.gr.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_screen.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/video_player/video_player.dart';
|
||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||
import 'package:fladder/util/list_extensions.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
|
|
@ -75,9 +74,11 @@ Future<void> _playVideo(
|
|||
return;
|
||||
}
|
||||
|
||||
final actualStartPosition = startPosition ?? await current.startDuration() ?? Duration.zero;
|
||||
|
||||
final loadedCorrectly = await ref.read(videoPlayerProvider.notifier).loadPlaybackItem(
|
||||
current,
|
||||
startPosition: startPosition,
|
||||
actualStartPosition,
|
||||
);
|
||||
|
||||
if (!loadedCorrectly) {
|
||||
|
|
@ -93,22 +94,16 @@ Future<void> _playVideo(
|
|||
|
||||
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) {
|
||||
fullScreenHelper.closeFullScreen(ref);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
await context.refreshData();
|
||||
}
|
||||
|
||||
onPlayerExit?.call();
|
||||
await ref.read(videoPlayerProvider.notifier).openPlayer(context);
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
fullScreenHelper.closeFullScreen(ref);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
await context.refreshData();
|
||||
}
|
||||
|
||||
onPlayerExit?.call();
|
||||
}
|
||||
|
||||
extension BookBaseModelExtension on BookModel? {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue