chore: Cleanup and performance improvements for posters

This commit is contained in:
PartyDonut 2025-10-12 18:30:43 +02:00
parent 9e5537089b
commit b28a409757
7 changed files with 437 additions and 456 deletions

View file

@ -18,6 +18,7 @@ class FlatButton extends ConsumerWidget {
final double elevation; final double elevation;
final bool showFeedback; final bool showFeedback;
final Clip clipBehavior; final Clip clipBehavior;
final List<Widget> overlays;
const FlatButton({ const FlatButton({
this.child, this.child,
this.onFocusChange, this.onFocusChange,
@ -32,6 +33,7 @@ class FlatButton extends ConsumerWidget {
this.elevation = 0, this.elevation = 0,
this.showFeedback = true, this.showFeedback = true,
this.clipBehavior = Clip.none, this.clipBehavior = Clip.none,
this.overlays = const [],
super.key, super.key,
}); });
@ -67,6 +69,7 @@ class FlatButton extends ConsumerWidget {
), ),
), ),
), ),
...overlays,
], ],
); );
} }

View file

@ -70,6 +70,7 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
final opacity = (constraints.maxWidth / maxExtent); final opacity = (constraints.maxWidth / maxExtent);
return FocusButton( return FocusButton(
onTap: () => widget.items[index].navigateTo(context), onTap: () => widget.items[index].navigateTo(context),
borderRadius: border,
onFocusChanged: (hover) { onFocusChanged: (hover) {
context.ensureVisible(); context.ensureVisible();
}, },
@ -155,9 +156,6 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
), ),
), ),
), ),
ExcludeFocus(
child: BannerPlayButton(item: widget.items[index]),
),
IgnorePointer( IgnorePointer(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -171,6 +169,11 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
), ),
], ],
), ),
overlays: [
ExcludeFocus(
child: BannerPlayButton(item: widget.items[index]),
),
],
); );
}, },
), ),

View file

@ -1,3 +1,4 @@
// poster_image.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -54,7 +55,7 @@ class PosterImage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final posterRadius = FladderTheme.smallShape.borderRadius; final radius = FladderTheme.smallShape.borderRadius;
final padding = const EdgeInsets.all(5); final padding = const EdgeInsets.all(5);
final myKey = key ?? UniqueKey(); final myKey = key ?? UniqueKey();
@ -74,66 +75,18 @@ class PosterImage extends ConsumerWidget {
} }
}, },
onFocusChanged: onFocusChanged, onFocusChanged: onFocusChanged,
onLongPress: () { onLongPress: () => _showBottomSheet(context, ref),
showBottomSheetPill( onSecondaryTapDown: (details) => _showContextMenu(context, ref, details.globalPosition),
context: context, child: Container(
item: poster, color: Theme.of(context).cardColor,
content: (scrollContext, scrollController) => ListView( child: FladderImage(
shrinkWrap: true,
controller: scrollController,
children: poster
.generateActions(
context,
ref,
exclude: excludeActions,
otherActions: otherActions,
onUserDataChanged: onUserDataChanged,
onDeleteSuccesFully: onItemRemoved,
onItemUpdated: onItemUpdated,
)
.listTileItems(scrollContext, useIcons: true),
),
);
},
onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position =
RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy);
await showMenu(
context: context,
position: position,
items: poster
.generateActions(
context,
ref,
exclude: excludeActions,
otherActions: otherActions,
onUserDataChanged: onUserDataChanged,
onDeleteSuccesFully: onItemRemoved,
onItemUpdated: onItemUpdated,
)
.popupMenuItems(useIcons: true),
);
},
child: Card(
elevation: 6,
color: Theme.of(context).colorScheme.secondaryContainer,
shape: RoundedRectangleBorder(
side: BorderSide(
width: 1.0,
color: Colors.white.withValues(alpha: 0.10),
),
borderRadius: posterRadius,
),
child: Stack(
fit: StackFit.expand,
children: [
FladderImage(
image: primaryPosters image: primaryPosters
? poster.images?.primary ? poster.images?.primary
: poster.getPosters?.primary ?? poster.getPosters?.backDrop?.lastOrNull, : poster.getPosters?.primary ?? poster.getPosters?.backDrop?.lastOrNull,
placeHolder: PosterPlaceholder(item: poster), placeHolder: PosterPlaceholder(item: poster),
), ),
),
overlays: [
if (poster.userData.progress > 0 && poster.type == FladderItemType.book) if (poster.userData.progress > 0 && poster.type == FladderItemType.book)
Align( Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
@ -159,7 +112,7 @@ class PosterImage extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15), color: Colors.black.withValues(alpha: 0.15),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary), border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
borderRadius: posterRadius, borderRadius: radius,
), ),
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: Stack( child: Stack(
@ -232,13 +185,12 @@ class PosterImage extends ConsumerWidget {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text( child: Text(
poster.title.maxLength(limitTo: 25), poster.title.maxLength(limitTo: 25),
style: style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold),
Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold),
), ),
), ),
), ),
if ((poster.unPlayedItemCount != null && poster is SeriesModel) || if (poster is! PhotoAlbumModel && (poster.unPlayedItemCount != null && poster is SeriesModel) ||
(poster.playAble && !poster.unWatched && poster is! PhotoAlbumModel)) (poster.playAble && !poster.unWatched))
IgnorePointer( IgnorePointer(
child: Align( child: Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
@ -304,10 +256,7 @@ class PosterImage extends ConsumerWidget {
) )
}, },
], ],
), focusedOverlays: [
),
overlays: [
//Poster Button
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[ if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[
// Play Button // Play Button
if (poster.playAble) if (poster.playAble)
@ -352,4 +301,45 @@ class PosterImage extends ConsumerWidget {
), ),
); );
} }
void _showBottomSheet(BuildContext context, WidgetRef ref) {
showBottomSheetPill(
context: context,
item: poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: poster
.generateActions(
context,
ref,
exclude: excludeActions,
otherActions: otherActions,
onUserDataChanged: onUserDataChanged,
onDeleteSuccesFully: onItemRemoved,
onItemUpdated: onItemUpdated,
)
.listTileItems(scrollContext, useIcons: true),
),
);
}
Future<void> _showContextMenu(BuildContext context, WidgetRef ref, Offset globalPos) async {
final position = RelativeRect.fromLTRB(globalPos.dx, globalPos.dy, globalPos.dx, globalPos.dy);
await showMenu(
context: context,
position: position,
items: poster
.generateActions(
context,
ref,
exclude: excludeActions,
otherActions: otherActions,
onUserDataChanged: onUserDataChanged,
onDeleteSuccesFully: onItemRemoved,
onItemUpdated: onItemUpdated,
)
.popupMenuItems(useIcons: true),
);
}
} }

View file

@ -112,9 +112,7 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
return ListView( return ListView(
shrinkWrap: true, shrinkWrap: true,
controller: scrollController, controller: scrollController,
children: [ children: episode.generateActions(context, ref).listTileItems(context, useIcons: true).toList(),
...episode.generateActions(context, ref).listTileItems(context, useIcons: true),
],
); );
}, },
); );
@ -165,20 +163,14 @@ class EpisodePoster extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Flexible( Flexible(
child: Card( child: FocusButton(
child: Stack(
fit: StackFit.expand,
children: [
FocusButton(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
onSecondaryTapDown: (details) async { onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition; Offset localPosition = details.globalPosition;
RelativeRect position = RelativeRect position =
RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy);
await showMenu(context: context, position: position, items: actions.popupMenuItems(useIcons: true));
await showMenu(
context: context, position: position, items: actions.popupMenuItems(useIcons: true));
}, },
child: Hero( child: Hero(
tag: heroTag ?? UniqueKey(), tag: heroTag ?? UniqueKey(),
@ -194,22 +186,6 @@ class EpisodePoster extends ConsumerWidget {
), ),
), ),
overlays: [ overlays: [
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer && actions.isNotEmpty)
ExcludeFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
),
),
),
],
),
if (!episodeAvailable) if (!episodeAvailable)
Align( Align(
alignment: Alignment.bottomLeft, alignment: Alignment.bottomLeft,
@ -274,7 +250,22 @@ class EpisodePoster extends ConsumerWidget {
), ),
), ),
], ],
focusedOverlays: [
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer && actions.isNotEmpty)
ExcludeFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,
), ),
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
),
),
),
],
), ),
), ),
if (showLabel) ...{ if (showLabel) ...{

View file

@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/screens/details_screens/person_detail_screen.dart'; import 'package:fladder/screens/details_screens/person_detail_screen.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart'; import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/focus_provider.dart';
@ -26,10 +26,11 @@ class PeopleRow extends ConsumerWidget {
child: SizedBox( child: SizedBox(
height: 75, height: 75,
width: 75, width: 75,
child: Card( child: Container(
elevation: 5, decoration: BoxDecoration(
shadowColor: Colors.transparent, borderRadius: FladderTheme.smallShape.borderRadius,
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.50), color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.50),
),
child: Center( child: Center(
child: Text( child: Text(
name.getInitials(), name.getInitials(),
@ -55,17 +56,19 @@ class PeopleRow extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Flexible( Flexible(
child: OpenContainer( child: Container(
closedColor: Colors.transparent, decoration: BoxDecoration(
closedElevation: 5, borderRadius: FladderTheme.smallShape.borderRadius,
openElevation: 0, color: Theme.of(context).cardTheme.color?.withValues(alpha: 0.1),
closedShape: const RoundedRectangleBorder(), ),
transitionType: ContainerTransitionType.fadeThrough,
openColor: Colors.transparent,
tappable: false,
closedBuilder: (context, action) => Card(
child: FocusButton( child: FocusButton(
onTap: () => action(), onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PersonDetailScreen(
person: person,
),
),
),
child: FladderImage( child: FladderImage(
image: person.image, image: person.image,
placeHolder: placeHolder(person.name), placeHolder: placeHolder(person.name),
@ -73,10 +76,6 @@ class PeopleRow extends ConsumerWidget {
), ),
), ),
), ),
openBuilder: (context, action) => PersonDetailScreen(
person: person,
),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ClickableText( ClickableText(

View file

@ -80,20 +80,42 @@ class SeasonPoster extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: Card(
child: Stack(
children: [
Positioned.fill(
child: Hero( child: Hero(
tag: myKey, tag: myKey,
child: FocusButton(
child: FladderImage( child: FladderImage(
image: season.getPosters?.primary ?? image: season.getPosters?.primary ??
season.parentImages?.backDrop?.firstOrNull ?? season.parentImages?.backDrop?.firstOrNull ??
season.parentImages?.primary, season.parentImages?.primary,
placeHolder: placeHolder(season.name), placeHolder: placeHolder(season.name),
), ),
onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position =
RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy);
await showMenu(
context: context,
position: position,
items: season.generateActions(context, ref).popupMenuItems(useIcons: true));
},
onTap: () async {
await season.navigateTo(context, ref: ref, tag: myKey);
if (!context.mounted) return;
context.refreshData();
},
onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch
? () {
showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: season.generateActions(context, ref).listTileItems(context, useIcons: true),
), ),
), );
}
: null,
overlays: [
if (season.images?.primary == null) if (season.images?.primary == null)
Align( Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
@ -141,35 +163,8 @@ class SeasonPoster extends ConsumerWidget {
], ],
), ),
), ),
Positioned.fill( ],
child: FocusButton( focusedOverlays: [
onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position = RelativeRect.fromLTRB(
localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy);
await showMenu(
context: context,
position: position,
items: season.generateActions(context, ref).popupMenuItems(useIcons: true));
},
onTap: () async {
await season.navigateTo(context, ref: ref, tag: myKey);
if (!context.mounted) return;
context.refreshData();
},
onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch
? () {
showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: season.generateActions(context, ref).listTileItems(context, useIcons: true),
),
);
}
: null,
overlays: [
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer)
ExcludeFocus( ExcludeFocus(
child: Align( child: Align(
@ -177,17 +172,13 @@ class SeasonPoster extends ConsumerWidget {
child: PopupMenuButton( child: PopupMenuButton(
tooltip: context.localized.options, tooltip: context.localized.options,
icon: const Icon(Icons.more_vert, color: Colors.white), icon: const Icon(Icons.more_vert, color: Colors.white),
itemBuilder: (context) => itemBuilder: (context) => season.generateActions(context, ref).popupMenuItems(useIcons: true),
season.generateActions(context, ref).popupMenuItems(useIcons: true),
), ),
), ),
), ),
], ],
), ),
), ),
],
),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ClickableText( ClickableText(

View file

@ -46,6 +46,7 @@ class FocusButton extends StatefulWidget {
final Widget? child; final Widget? child;
final bool autoFocus; final bool autoFocus;
final FocusNode? focusNode; final FocusNode? focusNode;
final List<Widget> focusedOverlays;
final List<Widget> overlays; final List<Widget> overlays;
final Function()? onTap; final Function()? onTap;
final Function()? onLongPress; final Function()? onLongPress;
@ -58,6 +59,7 @@ class FocusButton extends StatefulWidget {
this.child, this.child,
this.autoFocus = false, this.autoFocus = false,
this.focusNode, this.focusNode,
this.focusedOverlays = const [],
this.overlays = const [], this.overlays = const [],
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
@ -74,7 +76,7 @@ class FocusButton extends StatefulWidget {
class FocusButtonState extends State<FocusButton> { class FocusButtonState extends State<FocusButton> {
late FocusNode focusNode = widget.focusNode ?? FocusNode(); late FocusNode focusNode = widget.focusNode ?? FocusNode();
ValueNotifier<bool> onHover = ValueNotifier<bool>(false); ValueNotifier<bool> onHover = ValueNotifier(false);
Timer? _longPressTimer; Timer? _longPressTimer;
bool _longPressTriggered = false; bool _longPressTriggered = false;
bool _keyDownActive = false; bool _keyDownActive = false;
@ -153,48 +155,50 @@ class FocusButtonState extends State<FocusButton> {
}, },
onKeyEvent: _handleKey, onKeyEvent: _handleKey,
child: ExcludeFocus( child: ExcludeFocus(
child: Stack( child: ValueListenableBuilder(
children: [ valueListenable: onHover,
FlatButton( builder: (context, value, child) {
onTap: widget.onTap, return AnimatedContainer(
onSecondaryTapDown: widget.onSecondaryTapDown, duration: const Duration(milliseconds: 200),
onLongPress: widget.onLongPress, curve: Curves.easeInOut,
child: Container(
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: widget.borderRadius ?? FladderTheme.smallShape.borderRadius, borderRadius: widget.borderRadius ?? FladderTheme.smallShape.borderRadius,
), ),
child: widget.child, foregroundDecoration: BoxDecoration(
borderRadius: widget.borderRadius ?? FladderTheme.smallShape.borderRadius,
color: widget.darkOverlay
? Theme.of(context).colorScheme.primaryFixedDim.withValues(alpha: value ? 0.10 : 0.0)
: null,
border: Border.all(
width: value ? 3.5 : 2,
color: value ? Theme.of(context).colorScheme.primary : Colors.white.withAlpha(15),
), ),
), ),
Positioned.fill( child: FlatButton(
child: ValueListenableBuilder( onTap: widget.onTap,
valueListenable: onHover, onSecondaryTapDown: widget.onSecondaryTapDown,
builder: (context, value, child) => AnimatedOpacity( onLongPress: widget.onLongPress,
opacity: value ? 1 : 0,
duration: const Duration(milliseconds: 125),
child: Stack( child: Stack(
children: [ children: [
IgnorePointer( if (widget.child != null) widget.child!,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerLowest
.withValues(alpha: widget.darkOverlay ? 0.35 : 0),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.onPrimaryContainer),
borderRadius: widget.borderRadius ?? FladderTheme.smallShape.borderRadius,
),
),
),
...widget.overlays,
], ],
), ),
), overlays: [
if (widget.overlays.isNotEmpty) ...widget.overlays,
if (widget.focusedOverlays.isNotEmpty)
AnimatedOpacity(
opacity: value ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: Stack(
children: [...widget.focusedOverlays],
), ),
), ),
], ],
), ),
);
},
),
), ),
), ),
); );