refa: list sheet

Closes #369

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-04-01 23:21:07 +08:00
parent f9f30a5f13
commit f99740ef2d
12 changed files with 841 additions and 2599 deletions

View File

@@ -0,0 +1,645 @@
import 'dart:math';
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/icon_button.dart';
import 'package:PiliPlus/common/widgets/image_save.dart';
import 'package:PiliPlus/common/widgets/keep_alive_wrapper.dart';
import 'package:PiliPlus/common/widgets/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/spring_physics.dart';
import 'package:PiliPlus/common/widgets/stat/stat.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/bangumi/info.dart' as bangumi;
import 'package:PiliPlus/models/video_detail_res.dart' as video;
import 'package:PiliPlus/pages/common/common_slide_page.dart';
import 'package:PiliPlus/pages/video/detail/introduction/controller.dart';
import 'package:PiliPlus/pages/video/detail/introduction/widgets/page.dart';
import 'package:PiliPlus/utils/id_utils.dart';
import 'package:PiliPlus/utils/storage.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
enum EpisodeType { part, season, bangumi }
extension EpisodeTypeExt on EpisodeType {
String get title => ['分P', '合集', '番剧'][index];
}
class EpisodePanel extends CommonSlidePage {
const EpisodePanel({
super.key,
super.enableSlide,
required this.videoIntroController,
required this.heroTag,
required this.type,
// required this.count,
// required this.name,
required this.aid,
required this.bvid,
required this.cid,
required this.cover,
this.showTitle,
required this.list,
this.seasonId,
this.initialTabIndex = 0,
this.isSupportReverse,
this.isReversed,
this.onReverse,
required this.changeFucCall,
this.onClose,
});
final VideoIntroController videoIntroController;
final String heroTag;
final EpisodeType type;
// final int count;
// final String name;
final int? aid;
final String bvid;
final int cid;
final String? cover;
final bool? showTitle;
final List list;
final int? seasonId;
final int initialTabIndex;
final bool? isSupportReverse;
final bool? isReversed;
final Function changeFucCall;
final VoidCallback? onReverse;
final VoidCallback? onClose;
@override
State<EpisodePanel> createState() => _EpisodePanelState();
}
class _EpisodePanelState extends CommonSlidePageState<EpisodePanel>
with SingleTickerProviderStateMixin {
// tab
late final TabController _tabController = TabController(
initialIndex: widget.initialTabIndex,
length: widget.list.length,
vsync: this,
)..addListener(listener);
late final RxInt _currentTabIndex = _tabController.index.obs;
List get _getCurrEpisodes => widget.type == EpisodeType.season
? widget.list[_currentTabIndex.value].episodes
: widget.list[_currentTabIndex.value];
// item
late RxInt _currentItemIndex;
int get _findCurrentItemIndex => max(
0,
_getCurrEpisodes.indexWhere((item) => item.cid == widget.cid),
);
late final List<bool> _isReversed;
late final List<ItemScrollController> _itemScrollController;
// fav
Rx<LoadingState>? _favState;
late bool _isInit = true;
late final Color primary = Theme.of(context).colorScheme.primary;
final height = 120 / StyleString.aspectRatio + 10;
void listener() {
_currentTabIndex.value = _tabController.index;
}
@override
void didUpdateWidget(EpisodePanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.showTitle != false) {
return;
}
void jumpToCurrent() {
int newItemIndex = _findCurrentItemIndex;
if (_currentItemIndex.value != _findCurrentItemIndex) {
_currentItemIndex.value = newItemIndex;
try {
_itemScrollController[_currentTabIndex.value].jumpTo(
index: newItemIndex,
);
} catch (_) {}
}
}
// jump to current
if (_currentTabIndex.value != widget.initialTabIndex) {
_tabController.animateTo(
widget.initialTabIndex,
duration: const Duration(milliseconds: 200),
);
Future.delayed(const Duration(milliseconds: 300)).then((_) {
jumpToCurrent();
});
} else {
jumpToCurrent();
}
}
@override
void initState() {
super.initState();
_itemScrollController =
List.generate(widget.list.length, (_) => ItemScrollController());
_isReversed = List.generate(widget.list.length, (_) => false);
if (widget.type == EpisodeType.season && Accounts.main.isLogin) {
_favState = LoadingState.loading().obs;
VideoHttp.videoRelation(bvid: widget.bvid).then((result) {
if (result['status']) {
if (result['data']?['season_fav'] is bool) {
_favState!.value =
LoadingState.success(result['data']['season_fav']);
}
}
});
}
_currentItemIndex = _findCurrentItemIndex.obs;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_isInit = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
_itemScrollController[widget.initialTabIndex]
.jumpTo(index: _currentItemIndex.value);
} catch (_) {}
});
}
});
}
@override
void dispose() {
_tabController.removeListener(listener);
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_isInit) {
return CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
);
}
return super.build(context);
}
@override
Widget get buildPage => Material(
color: widget.showTitle == false
? Colors.transparent
: Theme.of(context).colorScheme.surface,
child: Column(
children: [
_buildToolbar,
if (widget.type == EpisodeType.season && widget.list.length > 1)
TabBar(
controller: _tabController,
padding: const EdgeInsets.only(right: 60),
isScrollable: true,
tabs: widget.list.map((item) => Tab(text: item.title)).toList(),
dividerHeight: 1,
dividerColor: Theme.of(context).dividerColor.withOpacity(0.1),
),
Expanded(
child: widget.type == EpisodeType.season
? Material(
color: Colors.transparent,
child: tabBarView(
controller: _tabController,
children: List.generate(
widget.list.length,
(index) => _buildBody(
index,
widget.list[index].episodes,
),
),
),
)
: enableSlide
? slideList()
: buildList,
),
],
),
);
@override
Widget get buildList => Material(
color: Colors.transparent,
child: _buildBody(0, widget.list[0]),
);
Widget _buildBody(int index, episodes) {
return KeepAliveWrapper(
builder: (context) => ScrollablePositionedList.builder(
padding: EdgeInsets.only(
top: 5,
bottom: MediaQuery.of(context).padding.bottom + 80,
),
reverse: _isReversed[index],
itemCount: episodes.length,
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
final episode = episodes[index];
return widget.type == EpisodeType.season &&
widget.showTitle != false &&
episode.pages.length > 1
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(
() => _buildEpisodeItem(
episode: episode,
index: index,
length: episodes.length,
isCurrentIndex:
_currentTabIndex.value == widget.initialTabIndex
? _currentItemIndex.value == index
: false,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 5),
child: PagesPanel(
list:
widget.initialTabIndex == _currentTabIndex.value &&
index == _currentItemIndex.value
? null
: episode.pages,
cover: episode.arc?.pic,
heroTag: widget.heroTag,
videoIntroController: widget.videoIntroController,
bvid: IdUtils.av2bv(episode.aid),
),
),
],
)
: Obx(
() => _buildEpisodeItem(
episode: episode,
index: index,
length: episodes.length,
isCurrentIndex:
_currentTabIndex.value == widget.initialTabIndex
? _currentItemIndex.value == index
: false,
),
);
},
itemScrollController: _itemScrollController[index],
),
);
}
Widget _buildEpisodeItem({
required dynamic episode,
required int index,
required int length,
required bool isCurrentIndex,
}) {
late String title;
String? cover;
num? duration;
int? pubdate;
int? view;
int? danmaku;
switch (episode) {
case video.Part():
cover = episode.firstFrame ?? widget.cover;
title = episode.pagePart!;
duration = episode.duration;
pubdate = episode.ctime;
break;
case video.EpisodeItem():
title = episode.title!;
cover = episode.arc?.pic;
duration = episode.arc?.duration;
pubdate = episode.arc?.pubdate;
view = episode.arc?.stat?.view;
danmaku = episode.arc?.stat?.danmaku;
break;
case bangumi.EpisodeItem():
if (episode.longTitle != null && episode.longTitle != "") {
dynamic leading = episode.title ?? index + 1;
title =
"${Utils.isStringNumeric(leading) ? '$leading话' : leading} ${episode.longTitle!}";
} else {
title = episode.title!;
}
cover = episode.cover;
duration = episode.duration == null ? null : episode.duration! ~/ 1000;
pubdate = episode.pubTime;
break;
}
return Material(
color: Colors.transparent,
child: SizedBox(
height: height,
child: InkWell(
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?.call();
if (widget.showTitle == false) {
_currentItemIndex.value = index;
}
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,
cover,
);
},
onLongPress: () {
if (cover?.isNotEmpty == true) {
imageSaveDialog(context: context, title: title, cover: cover);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: StyleString.safeSpace,
vertical: 5,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (cover?.isNotEmpty == true)
AspectRatio(
aspectRatio: StyleString.aspectRatio,
child: LayoutBuilder(
builder: (context, boxConstraints) {
return Stack(
clipBehavior: Clip.none,
children: [
NetworkImgLayer(
src: cover,
width: boxConstraints.maxWidth,
height: boxConstraints.maxHeight,
),
if (duration != null && duration > 0)
PBadge(
text: Utils.timeFormat(duration),
right: 6.0,
bottom: 6.0,
type: 'gray',
),
],
);
},
),
)
else if (isCurrentIndex)
Image.asset(
'assets/images/live.png',
color: primary,
height: 12,
semanticLabel: "正在播放:",
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
title,
textAlign: TextAlign.start,
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.bodyMedium!
.fontSize,
height: 1.42,
letterSpacing: 0.3,
fontWeight: isCurrentIndex ? FontWeight.bold : null,
color: isCurrentIndex ? primary : null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (pubdate != null)
Text(
Utils.dateFormat(pubdate),
maxLines: 1,
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.labelSmall!
.fontSize,
height: 1,
color: Theme.of(context).colorScheme.outline,
overflow: TextOverflow.clip,
),
),
if (view != null) ...[
Row(
children: [
StatView(
context: context,
theme: 'gray',
value: view,
),
if (danmaku != null) ...[
const SizedBox(width: 8),
StatDanMu(
context: context,
theme: 'gray',
value: danmaku,
),
],
],
)
],
],
),
),
if (episode.badge != null) ...[
if (episode.badge == '会员')
Image.asset(
'assets/images/big-vip.png',
height: 20,
semanticLabel: "大会员",
)
else
Text(episode.badge),
const SizedBox(width: 10),
],
],
),
),
),
),
);
}
Widget _buildFavBtn(LoadingState loadingState) {
return switch (loadingState) {
Success() => mediumButton(
tooltip: loadingState.response ? '取消订阅' : '订阅',
icon: loadingState.response
? Icons.notifications_off_outlined
: Icons.notifications_active_outlined,
onPressed: () async {
dynamic result = await VideoHttp.seasonFav(
isFav: loadingState.response,
seasonId: widget.seasonId,
);
if (result['status']) {
SmartDialog.showToast('${loadingState.response ? '取消' : ''}订阅成功');
_favState!.value = LoadingState.success(!loadingState.response);
} else {
SmartDialog.showToast(result['msg']);
}
},
),
_ => const SizedBox.shrink(),
};
}
Widget get _buildReverseBtn => mediumButton(
tooltip: widget.isReversed == true ? '正序播放' : '倒序播放',
icon: widget.isReversed == true
? MdiIcons.sortDescending
: MdiIcons.sortAscending,
onPressed: () {
widget.onReverse?.call();
},
);
Widget get _buildToolbar => Container(
height: 45,
padding: EdgeInsets.symmetric(
horizontal: widget.showTitle != false ? 14 : 6),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
),
),
child: Row(
children: [
if (widget.showTitle != false)
Text(
widget.type.title,
style: Theme.of(context).textTheme.titleMedium,
),
if (_favState != null) Obx(() => _buildFavBtn(_favState!.value)),
mediumButton(
tooltip: '跳至顶部',
icon: Icons.vertical_align_top,
onPressed: () {
try {
_itemScrollController[_currentTabIndex.value].scrollTo(
index: !_isReversed[_currentTabIndex.value]
? 0
: _getCurrEpisodes.length - 1,
duration: const Duration(milliseconds: 200),
);
} catch (e) {
debugPrint('to top: $e');
}
},
),
mediumButton(
tooltip: '跳至底部',
icon: Icons.vertical_align_bottom,
onPressed: () {
try {
_itemScrollController[_currentTabIndex.value].scrollTo(
index: !_isReversed[_currentTabIndex.value]
? _getCurrEpisodes.length - 1
: 0,
duration: const Duration(milliseconds: 200),
);
} catch (e) {
debugPrint('to bottom: $e');
}
},
),
mediumButton(
tooltip: '跳至当前',
icon: Icons.my_location,
onPressed: () async {
try {
if (_currentTabIndex.value != widget.initialTabIndex) {
_tabController.animateTo(widget.initialTabIndex);
await Future.delayed(const Duration(milliseconds: 225));
}
_itemScrollController[_currentTabIndex.value].scrollTo(
index: _currentItemIndex.value,
duration: const Duration(milliseconds: 200),
);
} catch (_) {}
},
),
if (widget.isSupportReverse == true)
Obx(
() {
return _currentTabIndex.value == widget.initialTabIndex
? _buildReverseBtn
: const SizedBox.shrink();
},
),
const Spacer(),
Obx(
() => mediumButton(
tooltip: _isReversed[_currentTabIndex.value] ? '顺序' : '倒序',
icon: !_isReversed[_currentTabIndex.value]
? MdiIcons.sortNumericAscending
: MdiIcons.sortNumericDescending,
onPressed: () {
setState(() {
_isReversed[_currentTabIndex.value] =
!_isReversed[_currentTabIndex.value];
});
},
),
),
if (widget.onClose != null)
mediumButton(
tooltip: '关闭',
icon: Icons.close,
onPressed: widget.onClose,
),
],
),
);
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class KeepAliveWrapper extends StatefulWidget {
const KeepAliveWrapper({
super.key,
required this.builder,
this.wantKeepAlive = true,
});
final WidgetBuilder builder;
final bool wantKeepAlive;
@override
State<KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<KeepAliveWrapper>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return widget.builder(context);
}
@override
bool get wantKeepAlive => widget.wantKeepAlive;
}

View File

@@ -1,531 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/icon_button.dart';
import 'package:PiliPlus/common/widgets/network_img_layer.dart';
import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/bangumi/info.dart' as bangumi;
import 'package:PiliPlus/models/video_detail_res.dart' as video;
import 'package:PiliPlus/pages/common/common_slide_page.dart';
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';
import 'package:PiliPlus/common/widgets/spring_physics.dart';
class ListSheetContent extends CommonSlidePage {
const ListSheetContent({
super.key,
this.index, // tab index
this.season,
this.episodes,
this.bvid,
this.aid,
required this.currentCid,
required this.changeFucCall,
this.onClose,
this.onReverse,
this.showTitle,
this.isSupportReverse,
this.isReversed,
super.enableSlide,
});
final dynamic index;
final dynamic season;
final dynamic episodes;
final String? bvid;
final int? aid;
final int currentCid;
final Function changeFucCall;
final VoidCallback? onClose;
final VoidCallback? onReverse;
final bool? showTitle;
final bool? isSupportReverse;
final bool? isReversed;
@override
State<ListSheetContent> createState() => _ListSheetContentState();
}
class _ListSheetContentState extends CommonSlidePageState<ListSheetContent>
with TickerProviderStateMixin {
late List<ItemScrollController> itemScrollController = [];
late int currentIndex = _currentIndex;
late List<bool> reverse;
int get _index => widget.index ?? 0;
late final bool _isList = widget.season != null &&
widget.season?.sections is List &&
widget.season.sections.length > 1;
dynamic get episodes =>
widget.episodes ?? widget.season?.sections[_index].episodes;
TabController? _ctr;
StreamController? _indexStream;
int? _seasonFav;
StreamController? _favStream;
@override
void didUpdateWidget(ListSheetContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.showTitle != false) {
return;
}
int currentIndex = _currentIndex;
void jumpToCurrent() {
if (this.currentIndex != currentIndex) {
this.currentIndex = currentIndex;
try {
itemScrollController[_index].jumpTo(
index: currentIndex,
);
} catch (_) {}
}
}
// jump to current
if (_ctr != null && widget.index != _ctr?.index) {
_ctr?.animateTo(_index, duration: const Duration(milliseconds: 200));
Future.delayed(const Duration(milliseconds: 300)).then((_) {
jumpToCurrent();
});
} else {
jumpToCurrent();
}
}
int get _currentIndex => max(
0,
_isList
? widget.season.sections[_index].episodes
.indexWhere((e) => e.cid == widget.currentCid)
: episodes.indexWhere((e) => e.cid == widget.currentCid));
void listener() {
_indexStream?.add(_ctr?.index);
}
late bool _isInit = true;
@override
void initState() {
super.initState();
if (_isList) {
_indexStream ??= StreamController<int>.broadcast();
_ctr = TabController(
vsync: this,
length: widget.season.sections.length,
initialIndex: _index,
)..addListener(listener);
}
itemScrollController = _isList
? List.generate(
widget.season.sections.length, (_) => ItemScrollController())
: [ItemScrollController()];
reverse = _isList
? List.generate(widget.season.sections.length, (_) => false)
: [false];
if (Accounts.main.isLogin && widget.bvid != null && widget.season != null) {
_favStream ??= StreamController<int>();
() async {
dynamic result = await VideoHttp.videoRelation(bvid: widget.bvid);
if (result['status']) {
_seasonFav = result['data']['season_fav'] ? 1 : 0;
_favStream?.add(_seasonFav);
}
}();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_isInit = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
itemScrollController[_index].jumpTo(index: currentIndex);
} catch (_) {}
});
}
});
}
@override
void dispose() {
_favStream?.close();
_favStream = null;
_indexStream?.close();
_indexStream = null;
_ctr?.removeListener(listener);
_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 != "") {
dynamic leading = episode.title ?? index + 1;
title =
"${Utils.isStringNumeric(leading) ? '$leading话' : leading} ${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?.call();
currentIndex = index;
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: (context, 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: "大会员",
)
else
Text(episode.badge),
const SizedBox(width: 10),
],
if (episode is! bangumi.EpisodeItem) Text('${index + 1}/$length'),
],
),
);
}
@override
Widget build(BuildContext context) {
if (_isInit) {
return CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
);
}
return enableSlide
? Padding(
padding: EdgeInsets.only(top: padding),
child: buildPage,
)
: buildPage;
}
@override
Widget get buildPage => Material(
color: widget.showTitle == false
? Colors.transparent
: Theme.of(context).colorScheme.surface,
child: Column(
children: [
Container(
height: 45,
padding: EdgeInsets.symmetric(
horizontal: widget.showTitle != false ? 14 : 6),
child: Row(
children: [
if (widget.showTitle != false)
Text(
'合集(${_isList ? widget.season.epCount : episodes?.length ?? ''})',
style: Theme.of(context).textTheme.titleMedium,
),
StreamBuilder(
stream: _favStream?.stream,
builder: (context, 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: () {
try {
itemScrollController[_ctr?.index ?? 0].scrollTo(
index: !reverse[_ctr?.index ?? 0]
? 0
: _isList
? widget.season.sections[_ctr?.index].episodes
.length -
1
: episodes.length - 1,
duration: const Duration(milliseconds: 200),
);
} catch (_) {}
},
),
mediumButton(
tooltip: '跳至底部',
icon: Icons.vertical_align_bottom,
onPressed: () {
try {
itemScrollController[_ctr?.index ?? 0].scrollTo(
index: !reverse[_ctr?.index ?? 0]
? _isList
? widget.season.sections[_ctr?.index].episodes
.length -
1
: episodes.length - 1
: 0,
duration: const Duration(milliseconds: 200),
);
} catch (_) {}
},
),
mediumButton(
tooltip: '跳至当前',
icon: Icons.my_location,
onPressed: () async {
if (_ctr != null && _ctr?.index != (_index)) {
_ctr?.animateTo(_index);
await Future.delayed(const Duration(milliseconds: 225));
}
try {
itemScrollController[_ctr?.index ?? 0].scrollTo(
index: currentIndex,
duration: const Duration(milliseconds: 200),
);
} catch (_) {}
},
),
if (widget.isSupportReverse == true)
if (!_isList)
_reverseButton
else
StreamBuilder(
stream: _indexStream?.stream,
initialData: _index,
builder: (context, snapshot) {
return snapshot.data == _index
? _reverseButton
: const SizedBox.shrink();
},
),
const Spacer(),
StreamBuilder(
stream: _indexStream?.stream,
initialData: _index,
builder: (context, snapshot) => mediumButton(
tooltip: reverse[snapshot.data] ? '顺序' : '倒序',
icon: !reverse[snapshot.data]
? MdiIcons.sortNumericAscending
: MdiIcons.sortNumericDescending,
onPressed: () {
setState(() {
reverse[_ctr?.index ?? 0] =
!reverse[_ctr?.index ?? 0];
});
},
),
),
if (widget.onClose != null)
mediumButton(
tooltip: '关闭',
icon: Icons.close,
onPressed: widget.onClose,
),
],
),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
if (_isList)
TabBar(
controller: _ctr,
padding: const EdgeInsets.only(right: 60),
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
? Material(
color: Colors.transparent,
child: tabBarView(
controller: _ctr,
children: List.generate(
widget.season.sections.length,
(index) => _buildBody(
index, widget.season.sections[index].episodes),
),
),
)
: enableSlide
? slideList()
: buildList,
),
],
),
);
@override
Widget get buildList => Material(
color: Colors.transparent,
child: _buildBody(null, episodes),
);
Widget get _reverseButton => mediumButton(
tooltip: widget.isReversed == true ? '正序播放' : '倒序播放',
icon: widget.isReversed == true
? MdiIcons.sortDescending
: MdiIcons.sortAscending,
onPressed: () async {
if (widget.showTitle == false) {
// jump to current
if (_ctr != null && _ctr?.index != (_index)) {
_ctr?.animateTo(_index);
await Future.delayed(const Duration(milliseconds: 225));
}
try {
itemScrollController[_ctr?.index ?? 0].scrollTo(
index: currentIndex,
duration: const Duration(milliseconds: 200),
);
} catch (_) {}
}
widget.onReverse?.call();
},
);
Widget _buildBody(i, episodes) => ScrollablePositionedList.separated(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 80,
),
reverse: reverse[i ?? 0],
itemCount: episodes.length,
physics: const AlwaysScrollableScrollPhysics(),
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: (context, index) => Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
);
}

View File

@@ -427,6 +427,7 @@ class Part {
Dimension? dimension;
String? firstFrame;
String? badge;
int? ctime;
Part({
this.cid,
@@ -439,6 +440,7 @@ class Part {
this.dimension,
this.firstFrame,
this.badge,
this.ctime,
});
fromRawJson(String str) => Part.fromJson(json.decode(str));
@@ -458,6 +460,7 @@ class Part {
: Dimension.fromJson(json["dimension"]);
firstFrame = json["first_frame"];
badge = json["badge"];
ctime = json["ctime"];
}
Map<String, dynamic> toJson() {
@@ -479,7 +482,7 @@ class Part {
class Stat {
int? aid;
int? view;
int? danmu;
int? danmaku;
int? reply;
int? favorite;
int? coin;
@@ -493,7 +496,7 @@ class Stat {
Stat({
this.aid,
this.view,
this.danmu,
this.danmaku,
this.reply,
this.favorite,
this.coin,
@@ -512,7 +515,7 @@ class Stat {
Stat.fromJson(Map<String, dynamic> json) {
aid = json["aid"];
view = json["view"];
danmu = json["danmaku"];
danmaku = json["danmaku"];
reply = json["reply"];
favorite = json["favorite"];
coin = json["coin"];
@@ -529,7 +532,7 @@ class Stat {
data["aid"] = aid;
data["view"] = view;
data["danmaku"] = danmu;
data["danmaku"] = danmaku;
data["reply"] = reply;
data["favorite"] = favorite;
data["coin"] = coin;
@@ -640,12 +643,23 @@ class UgcSeason {
class Arc {
Arc({
this.pic,
this.duration,
this.pubdate,
this.stat,
});
String? pic;
num? duration;
int? pubdate;
Stat? stat;
Arc.fromJson(Map<String, dynamic> json) {
pic = json['pic'] as String?;
duration = json['duration'];
pubdate = json['pubdate'];
stat = json['stat'] == null
? null
: Stat.fromJson(json['stat'] as Map<String, dynamic>);
}
}
@@ -691,6 +705,7 @@ class EpisodeItem {
this.bvid,
this.badge,
this.arc,
this.pages,
});
int? seasonId;
int? sectionId;
@@ -704,6 +719,7 @@ class EpisodeItem {
String? bvid;
String? badge;
Arc? arc;
List<Part>? pages;
EpisodeItem.fromJson(Map<String, dynamic> json) {
seasonId = json['season_id'];
@@ -720,5 +736,7 @@ class EpisodeItem {
arc = json['arc'] == null
? null
: Arc.fromJson(json['arc'] as Map<String, dynamic>);
pages =
(json['pages'] as List?)?.map((json) => Part.fromJson(json)).toList();
}
}

View File

@@ -1,4 +1,4 @@
library video_detail;
export './controller.dart';
export './view.dart';
export './view_v.dart';

View File

@@ -778,7 +778,11 @@ class VideoIntroController extends GetxController
final int currentIndex = episodes.indexWhere((e) =>
e.cid ==
(skipPages ? videoDetail.value.pages!.first.cid : lastPlayCid.value));
(skipPages
? videoDetail.value.isPageReversed == true
? videoDetail.value.pages!.last.cid
: videoDetail.value.pages!.first.cid
: lastPlayCid.value));
int prevIndex = currentIndex - 1;
final PlayRepeat playRepeat = videoDetailCtr.plPlayerController.playRepeat;
@@ -838,8 +842,11 @@ class VideoIntroController extends GetxController
final int currentIndex = episodes.indexWhere((e) =>
e.cid ==
(skipPages
? videoDetail.value.pages!.first.cid
? videoDetail.value.isPageReversed == true
? videoDetail.value.pages!.last.cid
: videoDetail.value.pages!.first.cid
: videoDetailCtr.cid.value));
int nextIndex = currentIndex + 1;
if (isPages.not &&

View File

@@ -627,7 +627,7 @@ class _VideoInfoState extends State<VideoInfo> with TickerProviderStateMixin {
context: context,
theme: 'gray',
value: Utils.numFormat(!widget.loadingStatus
? videoDetail.stat?.danmu ?? '-'
? videoDetail.stat?.danmaku ?? '-'
: videoItem['stat']?.danmu ?? '-'),
size: 'medium',
textColor: t.colorScheme.outline,
@@ -858,7 +858,6 @@ class _VideoInfoState extends State<VideoInfo> with TickerProviderStateMixin {
heroTag: widget.heroTag,
videoIntroController: videoIntroController,
bvid: videoIntroController.bvid,
changeFuc: videoIntroController.changeSeasonOrbangu,
showEpisodes: widget.showEpisodes,
),
),

View File

@@ -12,16 +12,20 @@ import '../../../../../utils/id_utils.dart';
class PagesPanel extends StatefulWidget {
const PagesPanel({
super.key,
this.list,
this.cover,
required this.bvid,
required this.changeFuc,
required this.heroTag,
required this.showEpisodes,
this.showEpisodes,
required this.videoIntroController,
});
final List<Part>? list;
final String? cover;
final String bvid;
final Function changeFuc;
final String heroTag;
final Function showEpisodes;
final Function? showEpisodes;
final VideoIntroController videoIntroController;
@override
@@ -30,36 +34,46 @@ class PagesPanel extends StatefulWidget {
class _PagesPanelState extends State<PagesPanel> {
late int cid;
late int pageIndex;
int pageIndex = -1;
late VideoDetailController _videoDetailController;
final ScrollController _scrollController = ScrollController();
StreamSubscription? _listener;
List<Part> get pages => widget.videoIntroController.videoDetail.value.pages!;
List<Part> get pages =>
widget.list ?? widget.videoIntroController.videoDetail.value.pages!;
@override
void initState() {
super.initState();
cid = widget.videoIntroController.lastPlayCid.value;
_videoDetailController =
Get.find<VideoDetailController>(tag: widget.heroTag);
pageIndex = pages.indexWhere((Part e) => e.cid == cid);
_listener = _videoDetailController.cid.listen((int cid) {
this.cid = cid;
pageIndex = max(0, pages.indexWhere((Part e) => e.cid == cid));
if (!mounted) return;
setState(() {});
const double itemWidth = 150; // 每个列表项的宽度
final double targetOffset = (pageIndex * itemWidth - itemWidth / 2).clamp(
_scrollController.position.minScrollExtent,
_scrollController.position.maxScrollExtent);
// 滑动至目标位置
_scrollController.animateTo(
targetOffset,
duration: const Duration(milliseconds: 300), // 滑动动画持续时间
curve: Curves.easeInOut, // 滑动动画曲线
);
});
if (widget.list == null) {
cid = widget.videoIntroController.lastPlayCid.value;
_videoDetailController =
Get.find<VideoDetailController>(tag: widget.heroTag);
pageIndex = pages.indexWhere((Part e) => e.cid == cid);
_listener = _videoDetailController.cid.listen((int cid) {
this.cid = cid;
pageIndex = max(0, pages.indexWhere((Part e) => e.cid == cid));
if (!mounted) return;
setState(() {});
jumpToCurr();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
jumpToCurr();
});
}
}
void jumpToCurr() {
const double itemWidth = 150; // 每个列表项的宽度
final double targetOffset = (pageIndex * itemWidth - itemWidth / 2).clamp(
_scrollController.position.minScrollExtent,
_scrollController.position.maxScrollExtent);
// 滑动至目标位置
_scrollController.animateTo(
targetOffset,
duration: const Duration(milliseconds: 300), // 滑动动画持续时间
curve: Curves.easeInOut, // 滑动动画曲线
);
}
@override
@@ -73,46 +87,47 @@ class _PagesPanelState extends State<PagesPanel> {
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('视频选集 '),
Expanded(
child: Text(
' 正在播放:${pages[pageIndex].pagePart}',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.outline,
),
),
),
const SizedBox(width: 10),
SizedBox(
height: 34,
child: TextButton(
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),
onPressed: () => widget.showEpisodes(
null,
null,
pages,
widget.bvid,
IdUtils.bv2av(widget.bvid),
cid,
),
if (widget.showEpisodes != null)
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('视频选集 '),
Expanded(
child: Text(
'${pages.length}',
style: const TextStyle(fontSize: 13),
' 正在播放:${pages[pageIndex].pagePart}',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.outline,
),
),
),
),
],
const SizedBox(width: 10),
SizedBox(
height: 34,
child: TextButton(
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),
onPressed: () => widget.showEpisodes!(
null,
null,
pages,
widget.bvid,
IdUtils.bv2av(widget.bvid),
cid,
),
child: Text(
'${pages.length}',
style: const TextStyle(fontSize: 13),
),
),
),
],
),
),
),
SizedBox(
height: 35,
child: ListView.builder(
@@ -132,14 +147,17 @@ class _PagesPanelState extends State<PagesPanel> {
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => {
widget.changeFuc(
onTap: () {
if (widget.showEpisodes == null) {
Get.back();
}
widget.videoIntroController.changeSeasonOrbangu(
null,
widget.bvid,
pages[i].cid,
IdUtils.bv2av(widget.bvid),
null,
)
widget.cover,
);
},
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -160,10 +178,11 @@ class _PagesPanelState extends State<PagesPanel> {
pages[i].pagePart!,
maxLines: 1,
style: TextStyle(
fontSize: 13,
color: isCurrentIndex
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface),
fontSize: 13,
color: isCurrentIndex
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
))
],

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import 'dart:math';
import 'dart:ui';
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/list_sheet.dart';
import 'package:PiliPlus/common/widgets/episode_panel.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/main.dart';
import 'package:PiliPlus/models/common/reply_type.dart';
@@ -2075,18 +2075,23 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
heroTag: heroTag,
videoIntroController: videoIntroController,
bvid: videoIntroController.bvid,
changeFuc: videoIntroController.changeSeasonOrbangu,
showEpisodes: showEpisodes,
),
)
else
Expanded(
child: Obx(
() => ListSheetContent(
episodes: videoIntroController.videoDetail.value.pages,
() => EpisodePanel(
heroTag: heroTag,
videoIntroController: videoIntroController,
type: EpisodeType.part,
list: [videoIntroController.videoDetail.value.pages!],
cover: videoDetailController.videoItem['pic'],
bvid: videoDetailController.bvid,
aid: IdUtils.bv2av(videoDetailController.bvid),
currentCid: videoDetailController.cid.value,
cid: videoDetailController.cid.value,
// count: videoIntroController.videoDetail.value.pages!.length,
// name: videoIntroController.videoDetail.value.pages!,
isReversed:
videoIntroController.videoDetail.value.isPageReversed,
changeFucCall: videoDetailController.videoType ==
@@ -2127,12 +2132,23 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
),
Expanded(
child: Obx(
() => ListSheetContent(
index: videoDetailController.seasonIndex.value,
season: videoIntroController.videoDetail.value.ugcSeason,
() => EpisodePanel(
heroTag: heroTag,
videoIntroController: videoIntroController,
type: EpisodeType.season,
initialTabIndex: videoDetailController.seasonIndex.value,
cover: videoDetailController.videoItem['pic'],
seasonId:
videoIntroController.videoDetail.value.ugcSeason!.id,
list: videoIntroController
.videoDetail.value.ugcSeason!.sections!,
// count: videoIntroController
// .videoDetail.value.ugcSeason!.epCount!,
// name:
// videoIntroController.videoDetail.value.ugcSeason!.title!,
bvid: videoDetailController.bvid,
aid: IdUtils.bv2av(videoDetailController.bvid),
currentCid: videoDetailController.seasonCid ?? 0,
cid: videoDetailController.seasonCid ?? 0,
isReversed:
videoIntroController.videoDetail.value.isSeasonReversed,
changeFucCall: videoDetailController.videoType ==
@@ -2243,14 +2259,22 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
videoDetailController.showMediaListPanel(context);
return;
}
Widget listSheetContent([bool? enableSlide]) => ListSheetContent(
Widget listSheetContent([bool? enableSlide]) => EpisodePanel(
heroTag: heroTag,
videoIntroController: videoIntroController,
type: season != null
? EpisodeType.season
: episodes is List<video.Part>
? EpisodeType.part
: EpisodeType.bangumi,
cover: videoDetailController.videoItem['pic'],
enableSlide: enableSlide,
index: index,
season: season,
initialTabIndex: index ?? 0,
bvid: bvid,
aid: aid,
currentCid: cid,
episodes: episodes,
cid: cid,
seasonId: season?.id,
list: season != null ? season.sections : [episodes],
isReversed:
videoDetailController.videoType == SearchType.media_bangumi
? null

View File

@@ -13,7 +13,6 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@@ -483,11 +482,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
int currentCid = plPlayerController.cid;
String bvid = plPlayerController.bvid;
List episodes = [];
if (isPage) {
final List<Part> pages =
videoIntroController!.videoDetail.value.pages!;
episodes = pages;
} else if (isSeason) {
if (isSeason) {
final List<SectionItem> sections =
videoIntroController!.videoDetail.value.ugcSeason!.sections!;
for (int i = 0; i < sections.length; i++) {
@@ -500,6 +495,10 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
}
}
}
} else if (isPage) {
final List<Part> pages =
videoIntroController!.videoDetail.value.pages!;
episodes = pages;
} else if (isBangumi) {
episodes = (bangumiIntroController!.loadingState.value as Success)
.response
@@ -507,8 +506,10 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
}
widget.showEpisodes?.call(
index,
isPage ? null : videoIntroController?.videoDetail.value.ugcSeason,
episodes,
isSeason
? videoIntroController?.videoDetail.value.ugcSeason!
: null,
isSeason ? null : episodes,
bvid,
IdUtils.bv2av(bvid),
currentCid,

View File

@@ -63,7 +63,6 @@ class Routes {
// 热门
CustomGetPage(name: '/hot', page: () => const HotPage()),
// 视频详情
CustomGetPage(name: '/video', page: () => const VideoDetailPage()),
CustomGetPage(name: '/videoV', page: () => const VideoDetailPageV()),
//
CustomGetPage(name: '/webview', page: () => const WebviewPageNew()),