feat: Android TV support (#503)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-09-28 21:07:49 +02:00 committed by GitHub
parent 7ab8c015b9
commit c299492d6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
168 changed files with 12019 additions and 3073 deletions

View file

@ -156,7 +156,9 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
);
},
),
BannerPlayButton(item: widget.items[index]),
ExcludeFocus(
child: BannerPlayButton(item: widget.items[index]),
),
IgnorePointer(
child: Container(
decoration: BoxDecoration(

View file

@ -4,9 +4,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/humanize_duration.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/horizontal_list.dart';
@ -25,7 +24,7 @@ class ChapterRow extends ConsumerWidget {
label: context.localized.chapter(chapters.length),
height: AdaptiveLayout.poster(context).size / 1.75,
items: chapters,
itemBuilder: (context, index) {
itemBuilder: (context, index, selected) {
final chapter = chapters[index];
List<ItemAction> generateActions() {
return [
@ -34,16 +33,39 @@ class ChapterRow extends ConsumerWidget {
];
}
return AspectRatio(
aspectRatio: 1.75,
child: Card(
return FocusButton(
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: generateActions().popupMenuItems(),
);
},
onLongPress: () {
showBottomSheetPill(
context: context,
content: (context, scrollController) {
return ListView(
shrinkWrap: true,
controller: scrollController,
children: [
...generateActions().listTileItems(context),
],
);
},
);
},
child: AspectRatio(
aspectRatio: 1.75,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: CachedNetworkImage(
imageUrl: chapter.imageUrl,
fit: BoxFit.cover,
),
CachedNetworkImage(
imageUrl: chapter.imageUrl,
fit: BoxFit.cover,
),
Align(
alignment: Alignment.bottomLeft,
@ -64,49 +86,25 @@ class ChapterRow extends ConsumerWidget {
),
),
),
FlatButton(
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: generateActions().popupMenuItems(),
);
},
onLongPress: () {
showBottomSheetPill(
context: context,
content: (context, scrollController) {
return ListView(
shrinkWrap: true,
controller: scrollController,
children: [
...generateActions().listTileItems(context),
],
);
},
);
},
),
if (AdaptiveLayout.of(context).isDesktop)
DisableFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => generateActions().popupMenuItems(),
),
),
),
],
),
),
overlays: [
if (AdaptiveLayout.of(context).isDesktop)
ExcludeFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => generateActions().popupMenuItems(),
),
),
)
],
);
},
contentPadding: contentPadding,

View file

@ -9,10 +9,12 @@ class MediaHeader extends ConsumerWidget {
final String name;
final ImageData? logo;
final Function()? onTap;
final Alignment alignment;
const MediaHeader({
required this.name,
required this.logo,
this.onTap,
this.alignment = Alignment.bottomCenter,
super.key,
});
@ -48,7 +50,7 @@ class MediaHeader extends ConsumerWidget {
? FladderImage(
image: logo,
disableBlur: true,
alignment: Alignment.bottomCenter,
alignment: alignment,
imageErrorBuilder: (context, object, stack) => textWidget,
placeHolder: const SizedBox(height: 0),
fit: BoxFit.contain,

View file

@ -1,15 +1,19 @@
import 'package:flutter/material.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
class MediaPlayButton extends ConsumerWidget {
final ItemBaseModel? item;
final Function()? onPressed;
final Function()? onLongPressed;
final VoidCallback? onPressed;
final VoidCallback? onLongPressed;
const MediaPlayButton({
required this.item,
this.onPressed,
@ -19,66 +23,110 @@ class MediaPlayButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final resume = (item?.progress ?? 0) > 0;
Widget buttonBuilder(bool resume, ButtonStyle? style, Color? textColor) {
return ElevatedButton(
onPressed: onPressed,
onLongPress: onLongPressed,
style: style,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
item?.playButtonLabel(context) ?? "",
maxLines: 2,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: textColor,
),
),
final progress = (item?.progress ?? 0) / 100.0;
final radius = FladderTheme.defaultShape.borderRadius;
Widget buttonTitle(Color contentColor) {
return Padding(
padding: const EdgeInsets.all(10.0),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
item?.playButtonLabel(context) ?? "",
maxLines: 2,
overflow: TextOverflow.clip,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: contentColor,
),
),
const SizedBox(width: 4),
const Icon(
IconsaxPlusBold.play,
),
],
),
),
const SizedBox(width: 4),
Icon(
IconsaxPlusBold.play,
color: contentColor,
),
],
),
);
}
return AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: onPressed != null
? Stack(
children: [
buttonBuilder(resume, null, null),
IgnorePointer(
child: ClipRect(
child: Align(
alignment: Alignment.centerLeft,
widthFactor: (item?.progress ?? 0) / 100,
child: buttonBuilder(
resume,
ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.primary),
foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary),
iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary),
child: onPressed == null
? const SizedBox.shrink(key: ValueKey('empty'))
: TextButton(
onPressed: onPressed,
onLongPress: onLongPressed,
autofocus: ref.read(argumentsStateProvider).htpcMode,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
),
onFocusChange: (value) {
if (value) {
context.ensureVisible(
alignment: 1.0,
);
}
},
child: Padding(
padding: const EdgeInsets.all(2.0),
child: Stack(
alignment: Alignment.center,
children: [
// Progress background
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
boxShadow: [
BoxShadow(
blurRadius: 8.0,
offset: const Offset(0, 2),
color: Colors.black.withValues(alpha: 0.3),
)
],
borderRadius: radius,
),
Theme.of(context).colorScheme.onPrimary,
),
),
),
// Button content
buttonTitle(Theme.of(context).colorScheme.primary),
Positioned.fill(
child: ClipRect(
clipper: _ProgressClipper(
progress,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: radius,
),
child: buttonTitle(Theme.of(context).colorScheme.onPrimary),
),
),
),
],
),
],
)
: Container(
key: UniqueKey(),
),
),
);
}
}
class _ProgressClipper extends CustomClipper<Rect> {
final double progress;
_ProgressClipper(this.progress);
@override
Rect getClip(Size size) {
final w = (progress.clamp(0.0, 1.0) * size.width);
return Rect.fromLTWH(0, 0, w, size.height);
}
@override
bool shouldReclip(covariant _ProgressClipper old) => old.progress != progress;
}

View file

@ -1,4 +1,3 @@
import 'package:fladder/models/items/movie_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -7,14 +6,14 @@ import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/book_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/models/items/series_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/media/components/poster_placeholder.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/humanize_duration.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/localization_helper.dart';
@ -26,7 +25,6 @@ import 'package:fladder/widgets/shared/status_card.dart';
class PosterImage extends ConsumerStatefulWidget {
final ItemBaseModel poster;
final bool heroTag;
final bool? selected;
final ValueChanged<bool>? playVideo;
final bool inlineTitle;
@ -36,9 +34,11 @@ class PosterImage extends ConsumerStatefulWidget {
final Function(ItemBaseModel newItem)? onItemUpdated;
final Function(ItemBaseModel oldItem)? onItemRemoved;
final Function(Function() action, ItemBaseModel item)? onPressed;
final bool primaryPosters;
final Function(bool focus)? onFocusChanged;
const PosterImage({
required this.poster,
this.heroTag = false,
this.selected,
this.playVideo,
this.inlineTitle = false,
@ -48,6 +48,8 @@ class PosterImage extends ConsumerStatefulWidget {
this.otherActions = const [],
this.onPressed,
this.onUserDataChanged,
this.primaryPosters = false,
this.onFocusChanged,
super.key,
});
@ -56,15 +58,8 @@ class PosterImage extends ConsumerStatefulWidget {
}
class _PosterImageState extends ConsumerState<PosterImage> {
late String currentTag = widget.heroTag == true ? widget.poster.id : UniqueKey().toString();
bool hover = false;
final tag = UniqueKey();
void pressedWidget(BuildContext context) async {
if (widget.heroTag == false) {
setState(() {
currentTag = widget.poster.id;
});
}
if (widget.onPressed != null) {
widget.onPressed?.call(() async {
await navigateToDetails();
@ -78,7 +73,7 @@ class _PosterImageState extends ConsumerState<PosterImage> {
}
Future<void> navigateToDetails() async {
await widget.poster.navigateTo(context, ref: ref);
await widget.poster.navigateTo(context, ref: ref, tag: tag);
}
final posterRadius = FladderTheme.smallShape.borderRadius;
@ -87,302 +82,268 @@ class _PosterImageState extends ConsumerState<PosterImage> {
Widget build(BuildContext context) {
final poster = widget.poster;
final padding = const EdgeInsets.all(5);
return Hero(
tag: currentTag,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (event) => setState(() => hover = true),
onExit: (event) => setState(() => hover = false),
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,
tag: tag,
child: Card(
elevation: 6,
color: Theme.of(context).colorScheme.secondaryContainer,
shape: RoundedRectangleBorder(
side: BorderSide(
width: 1.0,
color: Colors.white.withValues(alpha: 0.10),
),
child: Stack(
fit: StackFit.expand,
children: [
FladderImage(
image: widget.poster.getPosters?.primary ?? widget.poster.getPosters?.backDrop?.lastOrNull,
placeHolder: PosterPlaceholder(item: widget.poster),
),
if (poster.userData.progress > 0 && widget.poster.type == FladderItemType.book)
Align(
alignment: Alignment.topLeft,
child: Padding(
padding: padding,
child: Card(
child: Padding(
padding: const EdgeInsets.all(5.5),
child: Text(
context.localized.page((widget.poster as BookModel).currentPage),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
borderRadius: posterRadius,
),
child: Stack(
fit: StackFit.expand,
children: [
FladderImage(
image: widget.primaryPosters
? widget.poster.images?.primary
: widget.poster.getPosters?.primary ?? widget.poster.getPosters?.backDrop?.lastOrNull,
placeHolder: PosterPlaceholder(item: widget.poster),
),
if (poster.userData.progress > 0 && widget.poster.type == FladderItemType.book)
Align(
alignment: Alignment.topLeft,
child: Padding(
padding: padding,
child: Card(
child: Padding(
padding: const EdgeInsets.all(5.5),
child: Text(
context.localized.page((widget.poster as BookModel).currentPage),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
),
),
),
if (widget.selected == true)
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
borderRadius: posterRadius,
),
clipBehavior: Clip.hardEdge,
child: Stack(
alignment: Alignment.topCenter,
children: [
Container(
color: Theme.of(context).colorScheme.primary,
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(2),
child: Text(
widget.poster.name,
maxLines: 2,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.bold),
),
),
)
],
),
),
if (widget.selected == true)
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
borderRadius: posterRadius,
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
clipBehavior: Clip.hardEdge,
child: Stack(
alignment: Alignment.topCenter,
children: [
if (widget.poster.userData.isFavourite)
const Row(
children: [
StatusCard(
color: Colors.red,
child: Icon(
IconsaxPlusBold.heart,
size: 21,
color: Colors.red,
),
),
],
),
if ((poster.userData.progress > 0 && poster.userData.progress < 100) &&
widget.poster.type != FladderItemType.book) ...{
const SizedBox(
height: 4,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 3).copyWith(bottom: 3).add(padding),
child: Card(
color: Colors.transparent,
elevation: 3,
shadowColor: Colors.transparent,
child: LinearProgressIndicator(
minHeight: 7.5,
backgroundColor: Theme.of(context).colorScheme.onPrimary.withValues(alpha: 0.5),
value: poster.userData.progress / 100,
borderRadius: BorderRadius.circular(2),
),
Container(
color: Theme.of(context).colorScheme.primary,
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(2),
child: Text(
widget.poster.name,
maxLines: 2,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.bold),
),
),
},
)
],
),
),
if (widget.inlineTitle)
IgnorePointer(
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
widget.poster.title.maxLength(limitTo: 25),
style:
Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.poster.userData.isFavourite)
const Row(
children: [
StatusCard(
color: Colors.red,
child: Icon(
IconsaxPlusBold.heart,
size: 21,
color: Colors.red,
),
),
],
),
if ((poster.userData.progress > 0 && poster.userData.progress < 100) &&
widget.poster.type != FladderItemType.book) ...{
const SizedBox(
height: 4,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 3).copyWith(bottom: 3).add(padding),
child: Card(
color: Colors.transparent,
elevation: 3,
shadowColor: Colors.transparent,
child: LinearProgressIndicator(
minHeight: 7.5,
backgroundColor: Theme.of(context).colorScheme.onPrimary.withValues(alpha: 0.5),
value: poster.userData.progress / 100,
borderRadius: BorderRadius.circular(2),
),
),
),
},
],
),
),
if (widget.inlineTitle)
Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
widget.poster.title.maxLength(limitTo: 25),
style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold),
),
),
if ((widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel)
|| (widget.poster is MovieModel && !widget.poster.unWatched))
IgnorePointer(
child: Align(
alignment: Alignment.topRight,
child: StatusCard(
color: Theme.of(context).colorScheme.primary,
useFittedBox: widget.poster.unPlayedItemCount != 0,
child: Padding(
padding: const EdgeInsets.all(6),
child: widget.poster.unPlayedItemCount != 0
? Container(
constraints: const BoxConstraints(minWidth: 16),
child: Text(
widget.poster.userData.unPlayedItemCount.toString(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
overflow: TextOverflow.visible,
fontSize: 14,
),
),
)
: Icon(
Icons.check_rounded,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
),
if (widget.poster.overview.runTime != null &&
((widget.poster is PhotoModel) &&
(widget.poster as PhotoModel).internalType == FladderItemType.video)) ...{
Align(
),
if ((widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel) ||
(widget.poster is MovieModel && !widget.poster.unWatched))
IgnorePointer(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: padding,
child: Card(
elevation: 5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.poster.overview.runTime.humanizeSmall ?? "",
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
child: StatusCard(
color: Theme.of(context).colorScheme.primary,
useFittedBox: widget.poster.unPlayedItemCount != 0,
child: Padding(
padding: const EdgeInsets.all(6),
child: widget.poster.unPlayedItemCount != 0
? Container(
constraints: const BoxConstraints(minWidth: 16),
child: Text(
widget.poster.userData.unPlayedItemCount.toString(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
overflow: TextOverflow.visible,
fontSize: 14,
),
),
)
: Icon(
Icons.check_rounded,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 2),
Icon(
Icons.play_arrow_rounded,
color: Theme.of(context).colorScheme.onSurface,
),
],
),
),
),
),
),
if (widget.poster.overview.runTime != null &&
((widget.poster is PhotoModel) &&
(widget.poster as PhotoModel).internalType == FladderItemType.video)) ...{
Align(
alignment: Alignment.topRight,
child: Padding(
padding: padding,
child: Card(
elevation: 5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.poster.overview.runTime.humanizeSmall ?? "",
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 2),
Icon(
Icons.play_arrow_rounded,
color: Theme.of(context).colorScheme.onSurface,
),
],
),
),
),
)
},
//Desktop overlay
if (AdaptiveLayout.of(context).inputDevice != InputDevice.touch &&
widget.poster.type != FladderItemType.person)
AnimatedOpacity(
opacity: hover ? 1 : 0,
duration: const Duration(milliseconds: 125),
child: Stack(
fit: StackFit.expand,
children: [
//Hover color overlay
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
borderRadius: posterRadius,
)),
//Poster Button
Focus(
onFocusChange: (value) => setState(() => hover = value),
child: FlatButton(
onTap: () => pressedWidget(context),
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: widget.poster
.generateActions(
context,
ref,
exclude: widget.excludeActions,
otherActions: widget.otherActions,
onUserDataChanged: widget.onUserDataChanged,
onDeleteSuccesFully: widget.onItemRemoved,
onItemUpdated: widget.onItemUpdated,
)
.popupMenuItems(useIcons: true),
);
},
),
),
//Play Button
if (widget.poster.playAble)
DisableFocus(
child: Align(
alignment: Alignment.center,
child: IconButton.filledTonal(
onPressed: () => widget.playVideo?.call(false),
icon: const Icon(
IconsaxPlusBold.play,
size: 32,
),
),
),
),
DisableFocus(
child: Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton(
tooltip: "Options",
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => widget.poster
.generateActions(
context,
ref,
exclude: widget.excludeActions,
otherActions: widget.otherActions,
onUserDataChanged: widget.onUserDataChanged,
onDeleteSuccesFully: widget.onItemRemoved,
onItemUpdated: widget.onItemUpdated,
)
.popupMenuItems(useIcons: true),
),
],
),
),
),
],
),
)
},
FocusButton(
onTap: () => pressedWidget(context),
onFocusChanged: widget.onFocusChanged,
onLongPress: () {
showBottomSheetPill(
context: context,
item: widget.poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: widget.poster
.generateActions(
context,
ref,
exclude: widget.excludeActions,
otherActions: widget.otherActions,
onUserDataChanged: widget.onUserDataChanged,
onDeleteSuccesFully: widget.onItemRemoved,
onItemUpdated: widget.onItemUpdated,
)
.listTileItems(scrollContext, useIcons: true),
),
)
else
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => pressedWidget(context),
onLongPress: () {
showBottomSheetPill(
context: context,
item: widget.poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: widget.poster
);
},
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: widget.poster
.generateActions(
context,
ref,
exclude: widget.excludeActions,
otherActions: widget.otherActions,
onUserDataChanged: widget.onUserDataChanged,
onDeleteSuccesFully: widget.onItemRemoved,
onItemUpdated: widget.onItemUpdated,
)
.popupMenuItems(useIcons: true),
);
},
overlays: [
//Poster Button
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[
// Play Button
if (widget.poster.playAble)
Align(
alignment: Alignment.center,
child: IconButton.filledTonal(
onPressed: () => widget.playVideo?.call(false),
icon: const Icon(
IconsaxPlusBold.play,
size: 32,
),
),
),
Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton(
tooltip: "Options",
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => widget.poster
.generateActions(
context,
ref,
@ -392,14 +353,15 @@ class _PosterImageState extends ConsumerState<PosterImage> {
onDeleteSuccesFully: widget.onItemRemoved,
onItemUpdated: widget.onItemUpdated,
)
.listTileItems(scrollContext, useIcons: true),
.popupMenuItems(useIcons: true),
),
);
},
],
),
),
),
],
),
],
],
),
],
),
),
);

View file

@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/screens/details_screens/components/overview_header.dart';
import 'package:fladder/screens/shared/media/poster_row.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
class DetailedBanner extends ConsumerStatefulWidget {
final List<ItemBaseModel> posters;
final Function(ItemBaseModel selected) onSelect;
const DetailedBanner({
required this.posters,
required this.onSelect,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _DetailedBannerState();
}
class _DetailedBannerState extends ConsumerState<DetailedBanner> {
late ItemBaseModel selectedPoster = widget.posters.first;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final color = Theme.of(context).colorScheme.surface;
final stops = [0.05, 0.35, 0.65, 0.95];
return Column(
children: [
SizedBox(
width: double.infinity,
height: size.height * 0.50,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color.withValues(alpha: 0.85),
color.withValues(alpha: 0.75),
color.withValues(alpha: 0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Stack(
children: [
ExcludeFocus(
child: Align(
alignment: Alignment.topRight,
child: AspectRatio(
aspectRatio: 1.7,
child: ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
colors: [
Colors.white,
Colors.white,
Colors.white,
Colors.white.withAlpha(0),
],
stops: stops,
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
).createShader(bounds);
},
child: ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
colors: [
Colors.white.withAlpha(0),
Colors.white,
Colors.white,
Colors.white,
],
stops: stops,
begin: Alignment.centerLeft,
end: Alignment.centerRight,
).createShader(bounds);
},
child: FladderImage(
image: selectedPoster.images?.primary,
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: FractionallySizedBox(
widthFactor: 0.5,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
spacing: 16,
children: [
Flexible(
child: OverviewHeader(
name: selectedPoster.parentBaseModel.name,
subTitle: selectedPoster.label(context),
image: selectedPoster.getPosters,
logoAlignment: Alignment.centerLeft,
summary: selectedPoster.overview.summary,
productionYear: selectedPoster.overview.productionYear,
runTime: selectedPoster.overview.runTime,
genres: selectedPoster.overview.genreItems,
studios: selectedPoster.overview.studios,
officialRating: selectedPoster.overview.parentalRating,
communityRating: selectedPoster.overview.communityRating,
),
),
SizedBox(
height: size.height * 0.05,
)
],
),
),
),
],
),
),
),
FocusProvider(
autoFocus: true,
child: PosterRow(
key: const Key("detailed-banner-row"),
primaryPosters: true,
label: context.localized.nextUp,
posters: widget.posters,
onFocused: (poster) {
context.ensureVisible(
alignment: 1.0,
);
setState(() {
selectedPoster = poster;
});
widget.onSelect(poster);
},
),
)
],
);
}
}

View file

@ -6,11 +6,10 @@ import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/syncing/sync_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/clickable_text.dart';
@ -64,14 +63,14 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
EnumBox(
current: selectedSeason != null ? "${context.localized.season(1)} $selectedSeason" : context.localized.all,
itemBuilder: (context) => [
PopupMenuItem(
child: Text(context.localized.all),
onTap: () => setState(() => selectedSeason = null),
ItemActionButton(
label: Text(context.localized.all),
action: () => setState(() => selectedSeason = null),
),
...episodesBySeason.entries.map(
(e) => PopupMenuItem(
child: Text("${context.localized.season(1)} ${e.key}"),
onTap: () {
(e) => ItemActionButton(
label: Text("${context.localized.season(1)} ${e.key}"),
action: () {
setState(() => selectedSeason = e.key);
},
),
@ -84,7 +83,7 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
contentPadding: widget.contentPadding,
startIndex: indexOfCurrent,
items: episodes,
itemBuilder: (context, index) {
itemBuilder: (context, index, selected) {
final episode = episodes[index];
final isCurrentEpisode = index == indexOfCurrent;
return EpisodePoster(
@ -164,14 +163,43 @@ class EpisodePoster extends ConsumerWidget {
child: Stack(
fit: StackFit.expand,
children: [
FladderImage(
image: !episodeAvailable ? episode.parentImages?.primary : episode.images?.primary,
placeHolder: placeHolder,
blurOnly: !episodeAvailable
? true
: ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes))
? blur
: false,
FocusButton(
onTap: onTap,
onLongPress: onLongPress,
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: actions.popupMenuItems(useIcons: true));
},
child: FladderImage(
image: !episodeAvailable ? episode.parentImages?.primary : episode.images?.primary,
placeHolder: placeHolder,
blurOnly: !episodeAvailable
? true
: ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes))
? blur
: false,
decodeHeight: 250,
),
overlays: [
if (AdaptiveLayout.of(context).inputDevice == 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)
Align(
@ -236,36 +264,6 @@ class EpisodePoster extends ConsumerWidget {
value: episode.userData.progress / 100,
),
),
LayoutBuilder(
builder: (context, constraints) {
return FlatButton(
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: actions.popupMenuItems(useIcons: true));
},
onTap: onTap,
onLongPress: onLongPress,
);
},
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty)
DisableFocus(
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),
),
),
),
],
),
),

View file

@ -5,10 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/media/banner_play_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/themes_data.dart';
@ -99,9 +99,7 @@ class _MediaBannerState extends ConsumerState<MediaBanner> {
surfaceTintColor: overlayColor,
color: overlayColor,
child: MouseRegion(
onEnter: (event) => setState(() => showControls = true),
onHover: (event) => timer.reset(),
onExit: (event) => setState(() => showControls = false),
child: Stack(
fit: StackFit.expand,
children: [
@ -146,56 +144,52 @@ class _MediaBannerState extends ConsumerState<MediaBanner> {
],
),
),
child: Stack(
children: [
SizedBox(
width: double.infinity,
height: double.infinity,
child: Padding(
padding: const EdgeInsets.all(1),
child: FladderImage(
fit: BoxFit.cover,
image: currentItem.bannerImage,
),
child: FocusButton(
onTap: () => currentItem.navigateTo(context),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? () async {
interacting = true;
final poster = currentItem;
showBottomSheetPill(
context: context,
item: poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: poster
.generateActions(context, ref)
.listTileItems(scrollContext, useIcons: true),
),
);
interacting = false;
timer.reset();
}
: null,
onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? null
: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position = RelativeRect.fromLTRB(
localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
final poster = currentItem;
await showMenu(
context: context,
position: position,
items: poster.generateActions(context, ref).popupMenuItems(useIcons: true),
);
},
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Padding(
padding: const EdgeInsets.all(1),
child: FladderImage(
fit: BoxFit.cover,
image: currentItem.bannerImage,
),
),
FlatButton(
onTap: () => currentItem.navigateTo(context),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? () async {
interacting = true;
final poster = currentItem;
showBottomSheetPill(
context: context,
item: poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: poster
.generateActions(context, ref)
.listTileItems(scrollContext, useIcons: true),
),
);
interacting = false;
timer.reset();
}
: null,
onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? null
: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position = RelativeRect.fromLTRB(localPosition.dx - 320,
localPosition.dy, localPosition.dx, localPosition.dy);
final poster = currentItem;
await showMenu(
context: context,
position: position,
items: poster.generateActions(context, ref).popupMenuItems(useIcons: true),
);
},
),
],
),
),
),
),

View file

@ -6,9 +6,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/screens/details_screens/person_detail_screen.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/clickable_text.dart';
@ -47,7 +47,7 @@ class PeopleRow extends ConsumerWidget {
height: AdaptiveLayout.poster(context).size * 0.9,
contentPadding: contentPadding,
items: people,
itemBuilder: (context, index) {
itemBuilder: (context, index, selected) {
final person = people[index];
return AspectRatio(
aspectRatio: 0.6,
@ -63,19 +63,13 @@ class PeopleRow extends ConsumerWidget {
transitionType: ContainerTransitionType.fadeThrough,
openColor: Colors.transparent,
tappable: false,
closedBuilder: (context, action) => Stack(
children: [
Positioned.fill(
child: Card(
child: FladderImage(
image: person.image,
placeHolder: placeHolder(person.name),
fit: BoxFit.cover,
),
),
),
FlatButton(onTap: () => action()),
],
closedBuilder: (context, action) => FocusButton(
onTap: () => action(),
child: FladderImage(
image: person.image,
placeHolder: placeHolder(person.name),
fit: BoxFit.cover,
),
),
openBuilder: (context, action) => PersonDetailScreen(
person: person,

View file

@ -9,10 +9,12 @@ import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/clickable_text.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
@ -65,8 +67,13 @@ class PosterListItem extends ConsumerWidget {
color: Theme.of(context).colorScheme.primary.withValues(alpha: selected == true ? 0.25 : 0),
borderRadius: BorderRadius.circular(6),
),
child: InkWell(
child: FocusButton(
onTap: () => pressedWidget(context),
onFocusChanged: (focus) {
if (focus) {
context.ensureVisible();
}
},
onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position =

View file

@ -3,53 +3,56 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/screens/shared/media/poster_widget.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
import 'package:fladder/widgets/shared/horizontal_list.dart';
class PosterRow extends ConsumerStatefulWidget {
class PosterRow extends ConsumerWidget {
final List<ItemBaseModel> posters;
final String label;
final double? collectionAspectRatio;
final Function()? onLabelClick;
final EdgeInsets contentPadding;
final Function(ItemBaseModel focused)? onFocused;
final bool primaryPosters;
const PosterRow({
required this.posters,
this.contentPadding = const EdgeInsets.symmetric(horizontal: 16),
required this.label,
this.collectionAspectRatio,
this.onLabelClick,
this.onFocused,
this.primaryPosters = false,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _PosterRowState();
}
class _PosterRowState extends ConsumerState<PosterRow> {
late final controller = ScrollController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final dominantRatio = widget.collectionAspectRatio ?? widget.posters.getMostCommonType.aspectRatio;
Widget build(BuildContext context, WidgetRef ref) {
final dominantRatio = primaryPosters ? 1.2 : collectionAspectRatio ?? posters.getMostCommonType.aspectRatio;
return HorizontalList(
contentPadding: widget.contentPadding,
label: widget.label,
onLabelClick: widget.onLabelClick,
contentPadding: contentPadding,
label: label,
autoFocus: ref.read(argumentsStateProvider).htpcMode ? FocusProvider.autoFocusOf(context) : false,
onLabelClick: onLabelClick,
dominantRatio: dominantRatio,
items: widget.posters,
itemBuilder: (context, index) {
final poster = widget.posters[index];
items: posters,
onFocused: (index) {
if (onFocused != null) {
onFocused?.call(posters[index]);
} else {
context.ensureVisible();
}
},
itemBuilder: (context, index, selected) {
final poster = posters[index];
return PosterWidget(
key: Key(poster.id),
poster: poster,
aspectRatio: dominantRatio,
key: Key(poster.id),
primaryPosters: primaryPosters,
);
},
);

View file

@ -15,7 +15,6 @@ class PosterWidget extends ConsumerWidget {
final ItemBaseModel poster;
final Widget? subTitle;
final bool? selected;
final bool? heroTag;
final int maxLines;
final double? aspectRatio;
final bool inlineTitle;
@ -26,22 +25,27 @@ class PosterWidget extends ConsumerWidget {
final Function(ItemBaseModel newItem)? onItemUpdated;
final Function(ItemBaseModel oldItem)? onItemRemoved;
final Function(VoidCallback action, ItemBaseModel item)? onPressed;
const PosterWidget(
{required this.poster,
this.subTitle,
this.maxLines = 3,
this.selected,
this.heroTag,
this.aspectRatio,
this.inlineTitle = false,
this.underTitle = true,
this.excludeActions = const {},
this.otherActions = const [],
this.onUserDataChanged,
this.onItemUpdated,
this.onItemRemoved,
this.onPressed,
super.key});
final bool primaryPosters;
final Function(bool focus)? onFocusChanged;
const PosterWidget({
required this.poster,
this.subTitle,
this.maxLines = 3,
this.selected,
this.aspectRatio,
this.inlineTitle = false,
this.underTitle = true,
this.excludeActions = const {},
this.otherActions = const [],
this.onUserDataChanged,
this.onItemUpdated,
this.onItemRemoved,
this.onPressed,
this.primaryPosters = false,
this.onFocusChanged,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -54,7 +58,6 @@ class PosterWidget extends ConsumerWidget {
Expanded(
child: PosterImage(
poster: poster,
heroTag: heroTag ?? false,
selected: selected,
playVideo: (value) async => await poster.play(context, ref),
inlineTitle: inlineTitle,
@ -64,67 +67,71 @@ class PosterWidget extends ConsumerWidget {
onItemRemoved: onItemRemoved,
onItemUpdated: onItemUpdated,
onPressed: onPressed,
primaryPosters: primaryPosters,
onFocusChanged: onFocusChanged,
),
),
if (!inlineTitle && underTitle)
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: ClickableText(
onTap: AdaptiveLayout.viewSizeOf(context) != ViewSize.phone
? () => poster.parentBaseModel.navigateTo(context)
: null,
text: poster.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
ExcludeFocus(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: ClickableText(
onTap: AdaptiveLayout.viewSizeOf(context) != ViewSize.phone
? () => poster.parentBaseModel.navigateTo(context)
: null,
text: poster.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (subTitle != null) ...[
Flexible(
child: Opacity(
opacity: opacity,
child: subTitle!,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (subTitle != null) ...[
Flexible(
child: Opacity(
opacity: opacity,
child: subTitle!,
),
),
],
if (poster.subText?.isNotEmpty ?? false)
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subText ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
)
else
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subTextShort(context) ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
),
],
if (poster.subText?.isNotEmpty ?? false)
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subText ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
)
else
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subTextShort(context) ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subText?.isNotEmpty ?? false ? poster.subTextShort(context) ?? "" : "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
].take(maxLines).toList(),
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subText?.isNotEmpty ?? false ? poster.subTextShort(context) ?? "" : "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
].take(maxLines).toList(),
),
),
],
),

View file

@ -4,11 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/syncing/sync_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/clickable_text.dart';
@ -39,6 +38,7 @@ class SeasonsRow extends ConsumerWidget {
itemBuilder: (
context,
index,
selected,
) {
final season = (seasons ?? [])[index];
return SeasonPoster(
@ -141,46 +141,46 @@ class SeasonPoster extends ConsumerWidget {
],
),
),
LayoutBuilder(
builder: (context, constraints) {
return FlatButton(
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: () => onSeasonPressed?.call(season),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? () {
showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children:
season.generateActions(context, ref).listTileItems(context, useIcons: true),
),
);
}
: null,
);
},
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
DisableFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(Icons.more_vert, color: Colors.white),
itemBuilder: (context) => season.generateActions(context, ref).popupMenuItems(useIcons: true),
),
),
Positioned.fill(
child: FocusButton(
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: () => onSeasonPressed?.call(season),
onLongPress: AdaptiveLayout.of(context).inputDevice == 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.of(context).inputDevice == InputDevice.pointer)
ExcludeFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(Icons.more_vert, color: Colors.white),
itemBuilder: (context) =>
season.generateActions(context, ref).popupMenuItems(useIcons: true),
),
),
),
],
),
),
],
),
),