Fladder/lib/screens/shared/media/media_banner.dart
2025-10-03 13:02:51 +02:00

327 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:async/async.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/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';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
class MediaBanner extends ConsumerStatefulWidget {
final PageController? controller;
final List<ItemBaseModel> items;
final double maxHeight;
const MediaBanner({
this.controller,
required this.items,
this.maxHeight = 250,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _MediaBannerState();
}
class _MediaBannerState extends ConsumerState<MediaBanner> {
bool showControls = false;
bool interacting = false;
int currentPage = 0;
double dragOffset = 0;
double dragIntensity = 1;
double slidePosition = 1;
late final RestartableTimer timer = RestartableTimer(const Duration(seconds: 8), () => nextSlide());
@override
void initState() {
super.initState();
timer.reset();
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
void nextSlide() {
if (!interacting) {
setState(() {
if (currentPage == widget.items.length - 1) {
currentPage = 0;
} else {
currentPage++;
}
});
}
timer.reset();
}
void previousSlide() {
if (!interacting) {
setState(() {
if (currentPage == 0) {
currentPage = widget.items.length - 1;
} else {
currentPage--;
}
});
}
timer.reset();
}
@override
Widget build(BuildContext context) {
final overlayColor = ThemesData.of(context).dark.colorScheme.primaryContainer;
final currentItem = widget.items[currentPage.clamp(0, widget.items.length - 1)];
final double dragOpacity = (1 - dragOffset.abs()).clamp(0, 1);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: widget.maxHeight),
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
surfaceTintColor: overlayColor,
color: overlayColor,
child: MouseRegion(
onHover: (event) => timer.reset(),
child: Stack(
fit: StackFit.expand,
children: [
Dismissible(
key: const Key("Dismissable"),
direction: DismissDirection.horizontal,
onUpdate: (details) {
setState(() {
dragOffset = details.progress * 4;
});
},
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
previousSlide();
} else {
nextSlide();
}
return false;
},
child: AnimatedOpacity(
duration: const Duration(milliseconds: 125),
opacity: dragOpacity.abs(),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 125),
child: Container(
key: Key(currentItem.id),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
),
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.10),
strokeAlign: BorderSide.strokeAlignInside),
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topCenter,
colors: [
overlayColor.withValues(alpha: 0.85),
Colors.transparent,
],
),
),
child: FocusButton(
onTap: () => currentItem.navigateTo(context),
onLongPress: AdaptiveLayout.inputDeviceOf(context) == 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.inputDeviceOf(context) == 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,
),
),
),
),
),
),
),
),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
currentItem.title,
maxLines: 2,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
),
),
),
if (currentItem.label(context) != null || currentItem.subText != null)
Flexible(
child: Text(
currentItem.label(context) ?? currentItem.subText ?? "",
maxLines: 2,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.75),
),
),
),
].addInBetween(const SizedBox(height: 6)),
),
),
),
].addInBetween(const SizedBox(height: 16)),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedOpacity(
opacity: showControls ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filledTonal(
onPressed: () => nextSlide(),
icon: const Icon(IconsaxPlusLinear.arrow_right_3),
)
],
),
),
),
],
),
BannerPlayButton(item: currentItem),
],
),
),
),
),
if (widget.items.length > 1)
FractionallySizedBox(
widthFactor: 0.35,
child: FladderSlider(
value: currentPage.toDouble(),
min: 0,
animation: const Duration(milliseconds: 250),
thumbWidth: 24,
activeTrackColor: Theme.of(context).colorScheme.surfaceDim,
inactiveTrackColor: Theme.of(context).colorScheme.surfaceDim,
max: widget.items.length.toDouble() - 1,
onChanged: (value) => setState(() => currentPage = value.toInt()),
),
)
else
const SizedBox(height: 24)
],
),
);
}
void animateToTarget(int nextIndex) {
int step = currentPage < nextIndex ? 1 : -1;
void updateItem(int item) {
Future.delayed(Duration(milliseconds: 64 ~/ ((currentPage - nextIndex).abs() / 3)), () {
setState(() {
currentPage = item;
});
if (currentPage != nextIndex) {
updateItem(item + step);
}
});
timer.reset();
}
updateItem(currentPage + step);
}
}
class RoundedTrackShape extends RoundedRectSliderTrackShape {
@override
void paint(PaintingContext context, Offset offset,
{required RenderBox parentBox,
required SliderThemeData sliderTheme,
required Animation<double> enableAnimation,
required TextDirection textDirection,
required Offset thumbCenter,
Offset? secondaryOffset,
bool isDiscrete = false,
bool isEnabled = false,
double additionalActiveTrackHeight = 0}) {
super.paint(context, offset,
parentBox: parentBox,
sliderTheme: sliderTheme,
enableAnimation: enableAnimation,
textDirection: textDirection,
thumbCenter: thumbCenter,
secondaryOffset: secondaryOffset,
isDiscrete: isDiscrete,
isEnabled: isEnabled,
additionalActiveTrackHeight: additionalActiveTrackHeight);
}
}