import 'dart:ui'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../constants/breakpoints.dart'; import '../theme/app_theme.dart'; import 'banner_carousel_mixin.dart'; import 'circle_nav_icon_button.dart'; import 'frosted_panel.dart'; import 'smart_image.dart'; class BannerSlideData { final String? imageUrl; final String? fallbackImageUrl; final Map? imageHeaders; final String? logoUrl; final String title; final String? overview; final String? rating; final List primaryMetaLabels; final List secondaryMetaLabels; const BannerSlideData({ required this.imageUrl, required this.title, this.fallbackImageUrl, this.imageHeaders, this.logoUrl, this.overview, this.rating, this.primaryMetaLabels = const [], this.secondaryMetaLabels = const [], }); } class BannerCarouselScaffold extends StatelessWidget { final BannerCarouselMixin controller; final List slides; final ValueChanged onSlideTap; final bool reserveLogoArea; const BannerCarouselScaffold({ super.key, required this.controller, required this.slides, required this.onSlideTap, this.reserveLogoArea = false, }); @override Widget build(BuildContext context) { if (slides.isEmpty) return const SizedBox.shrink(); final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); if (compact) return _buildCompact(context); return _buildDesktop(context); } Widget _contentSwitcher({required bool compact}) { return IgnorePointer( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeOut, layoutBuilder: (currentChild, previousChildren) => Stack( fit: StackFit.expand, children: [ ...previousChildren, if (currentChild != null) currentChild, ], ), child: _BannerSlideContent( key: ValueKey(controller.currentIndex), data: slides[controller.currentIndex], compact: compact, reserveLogoArea: reserveLogoArea, ), ), ); } Widget _pageIndicator() { return FrostedPanel( radius: 999, enableBlur: false, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), color: Colors.black.withValues(alpha: 0.24), border: Border.all(color: Colors.white.withValues(alpha: 0.12)), child: Row( mainAxisSize: MainAxisSize.min, children: [ for (var index = 0; index < slides.length; index++) GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => controller.goToPage(index), child: AnimatedContainer( duration: const Duration(milliseconds: 240), curve: Curves.easeOut, width: controller.currentIndex == index ? 22 : 7, height: 7, margin: EdgeInsets.only( right: index == slides.length - 1 ? 0 : 6, ), decoration: BoxDecoration( color: controller.currentIndex == index ? Colors.white : const Color(0x66FFFFFF), borderRadius: BorderRadius.circular(999), ), ), ), ], ), ); } Widget _pageView({required bool compact}) { return PageView.builder( controller: controller.pageController, physics: compact ? null : const NeverScrollableScrollPhysics(), itemCount: slides.length, onPageChanged: controller.onPageChanged, itemBuilder: (context, index) { return _BannerSlide(data: slides[index], compact: compact); }, ); } Widget _buildCompact(BuildContext context) { final screenHeight = MediaQuery.sizeOf(context).height; final height = screenHeight * 0.55; final screenWidth = MediaQuery.sizeOf(context).width; controller.lastBannerWidth = screenWidth.isFinite && screenWidth > 0 ? screenWidth : null; return SizedBox( height: height, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => onSlideTap(controller.currentIndex), child: Stack( fit: StackFit.expand, children: [ _pageView(compact: true), _contentSwitcher(compact: true), Positioned( left: 0, right: 0, bottom: 20, child: Center(child: _pageIndicator()), ), ], ), ), ); } Widget _buildDesktop(BuildContext context) { final tokens = context.appTokens; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; controller.lastBannerWidth = width.isFinite && width > 0 ? width : null; final height = (width * 7 / 16).clamp(260.0, 500.0); return SizedBox( height: height, child: ClipRRect( borderRadius: BorderRadius.circular(tokens.panelRadius + 6), child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => controller.setHovering(true), onExit: (_) => controller.setHovering(false), child: Stack( fit: StackFit.expand, children: [ _BlurredBackdropLayer( data: slides[controller.currentIndex], animateSwitch: slides.length > 1, ), GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => onSlideTap(controller.currentIndex), child: _pageView(compact: false), ), _contentSwitcher(compact: false), Positioned(right: 22, bottom: 20, child: _pageIndicator()), if (controller.hovering && slides.length > 1) ...[ Positioned( left: 14, top: 0, bottom: 0, child: Center( child: CircleNavIconButton( icon: Icons.chevron_left_rounded, onPressed: controller.prevPage, iconSize: 28, ), ), ), Positioned( right: 14, top: 0, bottom: 0, child: Center( child: CircleNavIconButton( icon: Icons.chevron_right_rounded, onPressed: controller.nextPage, iconSize: 28, ), ), ), ], ], ), ), ), ); }, ), ); } } class _BlurredBackdropLayer extends StatelessWidget { final BannerSlideData data; final bool animateSwitch; const _BlurredBackdropLayer({ required this.data, required this.animateSwitch, }); @override Widget build(BuildContext context) { final image = _BlurredBackdropImage( key: ValueKey(data.imageUrl ?? '__none__'), data: data, ); if (!animateSwitch) return image; return AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeOut, layoutBuilder: (currentChild, previousChildren) { return Stack( fit: StackFit.expand, children: [ ...previousChildren, if (currentChild != null) currentChild, ], ); }, child: image, ); } } class _BlurredBackdropImage extends StatelessWidget { final BannerSlideData data; const _BlurredBackdropImage({super.key, required this.data}); @override Widget build(BuildContext context) { return ClipRect( child: Stack( fit: StackFit.expand, children: [ ImageFiltered( imageFilter: ImageFilter.blur( sigmaX: 36, sigmaY: 36, tileMode: TileMode.clamp, ), child: SmartImage( url: data.imageUrl, fallbackUrls: [ if (data.fallbackImageUrl != null) data.fallbackImageUrl!, ], httpHeaders: data.imageHeaders, borderRadius: 0, fit: BoxFit.cover, memCacheWidth: 800, fallbackIcon: null, placeholder: const ColoredBox(color: Colors.black), ), ), IgnorePointer( child: ColoredBox(color: Colors.black.withValues(alpha: 0.30)), ), ], ), ); } } class _BannerSlide extends StatelessWidget { final BannerSlideData data; final bool compact; const _BannerSlide({required this.data, this.compact = false}); @override Widget build(BuildContext context) { if (compact) { return Stack( fit: StackFit.expand, children: [ SmartImage( url: data.imageUrl, fallbackUrls: [ if (data.fallbackImageUrl != null) data.fallbackImageUrl!, ], borderRadius: 0, fit: BoxFit.cover, httpHeaders: data.imageHeaders, fallbackIcon: Icons.movie_outlined, placeholder: const SizedBox.expand(), ), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.0, 0.12, 0.4, 0.75, 1.0], colors: [ Colors.black.withValues(alpha: 0.35), Colors.black.withValues(alpha: 0.12), Colors.black.withValues(alpha: 0.0), Colors.black.withValues(alpha: 0.35), Colors.black.withValues(alpha: 0.70), ], ), ), ), ], ); } return Stack( fit: StackFit.expand, children: [ ImageFiltered( imageFilter: ImageFilter.blur( sigmaX: 36, sigmaY: 36, tileMode: TileMode.mirror, ), child: SmartImage( url: data.imageUrl, fallbackUrls: [ if (data.fallbackImageUrl != null) data.fallbackImageUrl!, ], borderRadius: 0, fit: BoxFit.cover, httpHeaders: data.imageHeaders, fallbackIcon: null, placeholder: const SizedBox.expand(), ), ), DecoratedBox( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.32), ), ), SmartImage( url: data.imageUrl, fallbackUrls: [ if (data.fallbackImageUrl != null) data.fallbackImageUrl!, ], borderRadius: 0, fit: BoxFit.contain, httpHeaders: data.imageHeaders, fallbackIcon: Icons.movie_outlined, placeholder: const SizedBox.expand(), ), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.0, 0.35, 0.6, 1.0], colors: [ Colors.black.withValues(alpha: 0.0), Colors.black.withValues(alpha: 0.03), Colors.black.withValues(alpha: 0.25), Colors.black.withValues(alpha: 0.55), ], ), ), ), ], ); } } class _BannerSlideContent extends StatelessWidget { final BannerSlideData data; final bool compact; final bool reserveLogoArea; const _BannerSlideContent({ super.key, required this.data, required this.compact, required this.reserveLogoArea, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; final ratingColor = tokens.ratingColor; final hasMetaInfo = data.rating != null || data.primaryMetaLabels.isNotEmpty || data.secondaryMetaLabels.isNotEmpty; final titleFontSize = compact ? 24.0 : 32.0; final titleText = Text( data.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleLarge?.copyWith( color: Colors.white, fontSize: titleFontSize, height: 1.0, ), ); final padding = compact ? const EdgeInsets.only(left: 24, bottom: 64, right: 24) : const EdgeInsets.only(left: 32, bottom: 32); return Align( alignment: Alignment.bottomLeft, child: Padding( padding: padding, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (reserveLogoArea) _buildLogoArea(titleText) else titleText, if (hasMetaInfo) ...[ const SizedBox(height: 10), _MetaRow( rating: data.rating, ratingColor: ratingColor, primaryLabels: data.primaryMetaLabels, secondaryLabels: data.secondaryMetaLabels, ), ], if ((data.overview ?? '').trim().isNotEmpty) ...[ const SizedBox(height: 12), Text( data.overview!, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: Colors.white.withValues(alpha: 0.78), height: 1.5, ), ), ], ], ), ), ), ); } Widget _buildLogoArea(Widget titleText) { final logoHeight = compact ? 56.0 : 72.0; final logoMaxWidth = compact ? 240.0 : 320.0; return ConstrainedBox( constraints: BoxConstraints(maxWidth: logoMaxWidth), child: SizedBox( width: double.infinity, height: logoHeight, child: data.logoUrl != null ? CachedNetworkImage( imageUrl: data.logoUrl!, httpHeaders: data.imageHeaders, fit: BoxFit.contain, alignment: Alignment.bottomLeft, memCacheWidth: 960, fadeInDuration: const Duration(milliseconds: 300), placeholderFadeInDuration: Duration.zero, fadeOutDuration: const Duration(milliseconds: 300), placeholder: (_, _) => const SizedBox.shrink(), errorWidget: (_, _, _) => Align(alignment: Alignment.bottomLeft, child: titleText), ) : Align(alignment: Alignment.bottomLeft, child: titleText), ), ); } } class _MetaRow extends StatelessWidget { final String? rating; final Color ratingColor; final List primaryLabels; final List secondaryLabels; const _MetaRow({ required this.rating, required this.ratingColor, required this.primaryLabels, required this.secondaryLabels, }); @override Widget build(BuildContext context) { final style = Theme.of(context).textTheme.bodySmall; final children = []; void addSep() { children.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( 'ยท', style: style?.copyWith(color: Colors.white.withValues(alpha: 0.38)), ), ), ); } if (rating != null) { children.add( Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.star_rounded, size: 13, color: ratingColor), const SizedBox(width: 3), Text( rating!, style: style?.copyWith( color: Colors.white, fontWeight: FontWeight.w700, ), ), ], ), ); } for (final label in primaryLabels) { if (children.isNotEmpty) addSep(); children.add( Text( label, style: style?.copyWith(color: Colors.white.withValues(alpha: 0.80)), ), ); } for (final label in secondaryLabels) { if (children.isNotEmpty) addSep(); children.add( Text( label, style: style?.copyWith(color: Colors.white.withValues(alpha: 0.62)), ), ); } return Row(mainAxisSize: MainAxisSize.min, children: children); } }