// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/models/home_feed.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; /// 推荐页顶部静态头图轮播:封面 + 底部剧名/简介 + 轮播点。 class HomeHeroCarousel extends StatefulWidget { const HomeHeroCarousel({ super.key, required this.banners, required this.dramaFor, required this.onWatch, this.active = true, }); final List banners; /// 按 banner 关联到的剧目;为空时只展示头图。 final Drama? Function(FeedBanner banner) dramaFor; /// 点击头图进入播放。 final ValueChanged onWatch; final bool active; /// 固定轮播高度;后台按该比例(屏宽 : height)出图即可铺满不裁切。 static const double height = 420; @override State createState() => _HomeHeroCarouselState(); } class _HomeHeroCarouselState extends State { static const Duration _autoInterval = Duration(milliseconds: 4500); static const Duration _pageDuration = Duration(milliseconds: 480); static const double _dotSize = 4; static const double _dotGap = 9; /// 轮播点距底边的距离。 static const double _dotBottom = 14; /// 文案区距底边:让出轮播点区域(底距 + 点直径)再留一段呼吸空间。 static const double _copyBottomInset = _dotBottom + _dotSize + 18; /// 无限轮播:PageView 用虚拟大页码,取模映射到真实 banner,首尾天然相接。 static const int _basePage = 1000; late final PageController _controller; Timer? _timer; int _page = 0; int _index = 0; bool _pressed = false; bool _tickerEnabled = true; /// 起始页码取 banner 数量的大倍数,向左右滑都不会到头。 static int _initialPageOf(int bannerCount) => bannerCount > 1 ? bannerCount * _basePage : 0; @override void initState() { super.initState(); _page = _initialPageOf(widget.banners.length); _controller = PageController(initialPage: _page); WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer()); } @override void didChangeDependencies() { super.didChangeDependencies(); final enabled = TickerMode.valuesOf(context).enabled; if (enabled != _tickerEnabled) { _tickerEnabled = enabled; _syncTimer(); } } @override void didUpdateWidget(covariant HomeHeroCarousel oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.banners.length != widget.banners.length) { // 首屏 banner 是异步到的:列表从空变有之后要把页码挪到虚拟起点, // 否则左滑第一下就到头了。 _page = _initialPageOf(widget.banners.length); _index = 0; _jumpToBasePage(); } if (oldWidget.banners.length != widget.banners.length || oldWidget.active != widget.active) { _syncTimer(); } } @override void dispose() { _timer?.cancel(); _controller.dispose(); super.dispose(); } void _syncTimer() { _timer?.cancel(); _timer = null; final canAdvance = mounted && widget.active && _tickerEnabled && !_pressed && widget.banners.length > 1; if (!canAdvance) return; _timer = Timer.periodic(_autoInterval, (_) => _advance()); } void _advance() { if (!_controller.hasClients || widget.banners.length < 2) return; // 虚拟页码无限,直接翻下一页即可,不用在末尾跳回第 0 页。 _controller.nextPage(duration: _pageDuration, curve: Curves.easeOutCubic); } /// 把 PageView 静默挪到虚拟起点:视觉上是同一张图,不会闪。 void _jumpToBasePage() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_controller.hasClients) return; if (_controller.page?.round() != _page) _controller.jumpToPage(_page); }); } @override Widget build(BuildContext context) { if (widget.banners.isEmpty) return const SizedBox.shrink(); return SizedBox( key: const Key('home-hero-carousel'), height: HomeHeroCarousel.height, child: Listener( onPointerDown: (_) { _pressed = true; _timer?.cancel(); }, onPointerUp: (_) { _pressed = false; _syncTimer(); }, onPointerCancel: (_) { _pressed = false; _syncTimer(); }, child: Stack( fit: StackFit.expand, children: [ PageView.builder( controller: _controller, // 数量大于 1 时用无限页码做无缝循环,取模映射到真实 banner。 itemCount: widget.banners.length > 1 ? null : widget.banners.length, onPageChanged: (value) => setState(() { _page = value; final count = widget.banners.length; _index = count == 0 ? 0 : value % count; }), itemBuilder: (context, page) => _bannerPage(context, page % widget.banners.length), ), Positioned( left: 0, right: 0, bottom: _dotBottom, child: _dots(context), ), ], ), ), ); } Widget _bannerPage(BuildContext context, int index) { final banner = widget.banners[index]; final drama = widget.dramaFor(banner); return Stack( fit: StackFit.expand, children: [ RemoteImage( banner.imageUrl, fit: BoxFit.cover, fallbackColor: AppColors.contentSurface, ), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0x99000000), Color(0x00000000), Color(0x66000000), Color(0xB3000000), ], stops: [0, 0.22, 0.62, 1], ), ), ), if (drama != null) Positioned( left: 28, right: 28, bottom: _copyBottomInset, child: _HeroCopy(key: Key('home-hero-copy-$index'), drama: drama), ), if (drama != null) Positioned.fill( child: Material( color: AppColors.transparent, child: InkWell( key: Key('home-hero-$index'), highlightColor: AppColors.transparent, onTap: () => widget.onWatch(drama), ), ), ), ], ); } Widget _dots(BuildContext context) { if (widget.banners.length < 2) return const SizedBox.shrink(); return ExcludeSemantics( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var index = 0; index < widget.banners.length; index++) Container( key: Key('home-hero-dot-$index'), width: _dotSize, height: _dotSize, margin: const EdgeInsets.symmetric(horizontal: _dotGap / 2), decoration: BoxDecoration( shape: BoxShape.circle, color: index == _index ? AppColors.brandRed : AppColors.textPrimary.withValues(alpha: 0.4), ), ), ], ), ); } } /// Hero 底部居中文案:剧名 + 简介。 class _HeroCopy extends StatelessWidget { const _HeroCopy({super.key, required this.drama}); final Drama drama; static const Color _titleCream = Color(0xFFE9D3B8); @override Widget build(BuildContext context) { final lang = Localizations.localeOf(context).languageCode; return Column( key: const Key('home-hero-copy'), mainAxisSize: MainAxisSize.min, children: [ Text( drama.localizedTitle(lang), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: const TextStyle( color: _titleCream, fontSize: 22, fontWeight: FontWeight.w400, height: 1.15, shadows: [Shadow(color: Color(0x80000000), blurRadius: 12)], ), ), const SizedBox(height: 8), Text( drama.localizedSynopsis(lang), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( color: AppColors.textPrimary.withValues(alpha: 0.95), fontSize: 15, fontWeight: FontWeight.w400, shadows: const [Shadow(color: Color(0x80000000), blurRadius: 8)], ), ), ], ); } }