import 'dart:async'; import 'package:PiliPalaX/common/constants.dart'; import 'package:PiliPalaX/common/widgets/network_img_layer.dart'; import 'package:PiliPalaX/http/video.dart'; import 'package:PiliPalaX/models/bangumi/info.dart' as bangumi; import 'package:PiliPalaX/models/video_detail_res.dart' as video; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../utils/storage.dart'; import '../../utils/utils.dart'; class ListSheet { ListSheet({ this.index, this.season, required this.episodes, this.bvid, this.aid, required this.currentCid, required this.changeFucCall, required this.context, this.scaffoldState, }); final dynamic index; final dynamic season; final dynamic episodes; final String? bvid; final int? aid; final int currentCid; final Function changeFucCall; final BuildContext context; final ScaffoldState? scaffoldState; late PersistentBottomSheetController bottomSheetController; Widget get listSheetContent => ListSheetContent( index: index, season: season, episodes: episodes, bvid: bvid, aid: aid, currentCid: currentCid, changeFucCall: changeFucCall, onClose: bottomSheetController.close, ); void buildShowBottomSheet() { bottomSheetController = scaffoldState?.showBottomSheet( (context) => listSheetContent, ) ?? showBottomSheet( context: context, builder: (context) => listSheetContent, ); } } class ListSheetContent extends StatefulWidget { const ListSheetContent({ super.key, this.index, this.season, required this.episodes, this.bvid, this.aid, required this.currentCid, required this.changeFucCall, required this.onClose, }); final dynamic index; final dynamic season; final dynamic episodes; final String? bvid; final int? aid; final int currentCid; final Function changeFucCall; final Function() onClose; @override State createState() => _ListSheetContentState(); } class _ListSheetContentState extends State with TickerProviderStateMixin { late List itemScrollController = []; late final int currentIndex = widget.episodes!.indexWhere((dynamic e) => e.cid == widget.currentCid) ?? 0; late List reverse; int get _index => widget.index ?? 0; bool get _isList => widget.season != null && widget.season?.sections is List && widget.season.sections.length > 1; TabController? _ctr; StreamController? _indexStream; int? _seasonFav; StreamController? _favStream; @override void initState() { super.initState(); if (_isList) { _indexStream = StreamController(); _ctr = TabController( vsync: this, length: widget.season.sections.length, initialIndex: _index, )..addListener(() { _indexStream?.add(_ctr?.index); }); } itemScrollController = _isList ? List.generate( widget.season.sections.length, (_) => ItemScrollController()) : [ItemScrollController()]; reverse = _isList ? List.generate(widget.season.sections.length, (_) => false) : [false]; WidgetsBinding.instance.addPostFrameCallback((_) { itemScrollController[_index].jumpTo(index: currentIndex); }); if (widget.bvid != null && widget.season != null) { _favStream = StreamController(); () async { dynamic result = await VideoHttp.videoRelation(bvid: widget.bvid); if (result['status']) { _seasonFav = result['data']['season_fav'] ? 1 : 0; _favStream?.add(_seasonFav); } }(); } } @override void dispose() { _favStream?.close(); _indexStream?.close(); _ctr?.removeListener(() {}); _ctr?.dispose(); super.dispose(); } Widget buildEpisodeListItem( dynamic episode, int index, int length, bool isCurrentIndex, ) { Color primary = Theme.of(context).colorScheme.primary; late String title; if (episode.runtimeType.toString() == "EpisodeItem") { if (episode.longTitle != null && episode.longTitle != "") { title = "第${(episode.title ?? '${index + 1}')}话 ${episode.longTitle!}"; } else { title = episode.title!; } } else if (episode.runtimeType.toString() == "PageItem") { title = episode.pagePart!; } else if (episode.runtimeType.toString() == "Part") { title = episode.pagePart!; // debugPrint("未知类型:${episode.runtimeType}"); } return ListTile( onTap: () { if (episode.badge != null && episode.badge == "会员") { dynamic userInfo = GStorage.userInfo.get('userInfoCache'); int vipStatus = 0; if (userInfo != null) { vipStatus = userInfo.vipStatus; } if (vipStatus != 1) { SmartDialog.showToast('需要大会员'); // return; } } SmartDialog.showToast('切换到:$title'); widget.onClose(); widget.changeFucCall( episode is bangumi.EpisodeItem ? episode.epId : null, episode.runtimeType.toString() == "EpisodeItem" ? episode.bvid : widget.bvid, episode.cid, episode.runtimeType.toString() == "EpisodeItem" ? episode.aid : widget.aid, episode is video.EpisodeItem ? episode.arc?.pic : episode is bangumi.EpisodeItem ? episode.cover : null, ); }, dense: false, leading: (episode is video.EpisodeItem && episode.arc?.pic != null) || (episode is video.Part && episode.firstFrame != null) || (episode is bangumi.EpisodeItem && episode.cover != null) ? Container( margin: const EdgeInsets.symmetric(vertical: 6), decoration: isCurrentIndex ? BoxDecoration( borderRadius: BorderRadius.circular(6), border: Border.all( width: 1.8, strokeAlign: BorderSide.strokeAlignOutside, color: Theme.of(context).colorScheme.primary, ), ) : null, child: LayoutBuilder( builder: (_, constraints) => NetworkImgLayer( radius: 6, src: episode is video.EpisodeItem ? episode.arc?.pic : episode is bangumi.EpisodeItem ? episode.cover : episode.firstFrame, width: constraints.maxHeight * StyleString.aspectRatio, height: constraints.maxHeight, ), ), ) : isCurrentIndex ? Image.asset( 'assets/images/live.png', color: primary, height: 12, semanticLabel: "正在播放:", ) : null, title: Text( title, style: TextStyle( fontSize: 14, fontWeight: isCurrentIndex ? FontWeight.bold : null, color: isCurrentIndex ? primary : Theme.of(context).colorScheme.onSurface, ), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (episode.badge != null) ...[ if (episode.badge == '会员') Image.asset( 'assets/images/big-vip.png', height: 20, semanticLabel: "大会员", ), if (episode.badge != '会员') Text(episode.badge), const SizedBox(width: 10), ], if (!(episode.runtimeType.toString() == 'EpisodeItem' && (episode.longTitle != null && episode.longTitle != ''))) Text('${index + 1}/$length'), ], ), ); } @override Widget build(BuildContext context) { return Container( height: Utils.getSheetHeight(context), color: Theme.of(context).colorScheme.surface, child: Column( children: [ Container( height: 45, padding: const EdgeInsets.only(left: 14, right: 14), child: Row( children: [ Text( '合集(${_isList ? widget.season.epCount : widget.episodes!.length})', style: Theme.of(context).textTheme.titleMedium, ), StreamBuilder( stream: _favStream?.stream, builder: (_, snapshot) => snapshot.hasData ? _mediumButton( tooltip: _seasonFav == 1 ? '取消订阅' : '订阅', icon: _seasonFav == 1 ? Icons.notifications_off_outlined : Icons.notifications_active_outlined, onPressed: () async { dynamic result = await VideoHttp.seasonFav( isFav: _seasonFav == 1, seasonId: widget.season.id, ); if (result['status']) { SmartDialog.showToast( '${_seasonFav == 1 ? '取消' : ''}订阅成功'); _seasonFav = _seasonFav == 1 ? 0 : 1; _favStream?.add(_seasonFav); } else { SmartDialog.showToast(result['msg']); } }, ) : const SizedBox.shrink(), ), _mediumButton( tooltip: '跳至顶部', icon: Icons.vertical_align_top, onPressed: () { itemScrollController[_ctr?.index ?? 0].scrollTo( index: !reverse[_ctr?.index ?? 0] ? 0 : _isList ? widget.season.sections[_ctr?.index].episodes .length - 1 : widget.episodes.length - 1, duration: const Duration(milliseconds: 200), ); }, ), _mediumButton( tooltip: '跳至底部', icon: Icons.vertical_align_bottom, onPressed: () { itemScrollController[_ctr?.index ?? 0].scrollTo( index: !reverse[_ctr?.index ?? 0] ? _isList ? widget.season.sections[_ctr?.index].episodes .length - 1 : widget.episodes.length - 1 : 0, duration: const Duration(milliseconds: 200), ); }, ), _mediumButton( tooltip: '跳至当前', icon: Icons.my_location, onPressed: () async { if (_ctr != null && _ctr?.index != (_index)) { _ctr?.animateTo(_index); await Future.delayed(const Duration(milliseconds: 225)); } itemScrollController[_ctr?.index ?? 0].scrollTo( index: currentIndex, duration: const Duration(milliseconds: 200), ); }, ), const Spacer(), StreamBuilder( stream: _indexStream?.stream, initialData: 0, builder: (_, snapshot) => _mediumButton( tooltip: reverse[snapshot.data] ? '正序' : '反序', icon: !reverse[snapshot.data] ? MdiIcons.sortAscending : MdiIcons.sortDescending, onPressed: () { setState(() { reverse[_ctr?.index ?? 0] = !reverse[_ctr?.index ?? 0]; }); }, ), ), _mediumButton( tooltip: '关闭', icon: Icons.close, onPressed: widget.onClose, ), ], ), ), Divider( height: 1, color: Theme.of(context).dividerColor.withOpacity(0.1), ), if (_isList) TabBar( controller: _ctr, isScrollable: true, tabs: (widget.season.sections as List) .map((item) => Tab(text: item.title)) .toList(), dividerHeight: 1, dividerColor: Theme.of(context).dividerColor.withOpacity(0.1), ), Expanded( child: _isList ? TabBarView( controller: _ctr, children: List.generate( widget.season.sections.length, (index) => _buildBody( index, widget.season.sections[index].episodes), ), ) : _buildBody(null, widget.episodes), ), ], ), ); } Widget _mediumButton({ String? tooltip, IconData? icon, VoidCallback? onPressed, }) { return SizedBox( width: 34, height: 34, child: IconButton( tooltip: tooltip, icon: Icon(icon), style: ButtonStyle( padding: WidgetStateProperty.all(EdgeInsets.zero), ), onPressed: onPressed, ), ); } Widget _buildBody(i, episodes) => Material( child: ScrollablePositionedList.separated( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom + 20, ), reverse: reverse[i ?? 0], itemCount: episodes.length, itemBuilder: (BuildContext context, int index) { return buildEpisodeListItem( episodes[index], index, episodes.length, i != null ? i == (_index) ? currentIndex == index : false : currentIndex == index, ); }, itemScrollController: itemScrollController[i ?? 0], separatorBuilder: (_, index) => Divider( height: 1, color: Theme.of(context).dividerColor.withOpacity(0.1), ), ), ); }