初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 小字筛选栏:文字 + 竖线分隔,用在标签/专题/搜索结果/短剧等列表页顶部
class DividerTabBar extends StatelessWidget {
final List<String> titles;
final int selectIndex;
final Function(int)? callback;
final AlignmentGeometry? alignment;
const DividerTabBar(
this.titles, {
super.key,
this.selectIndex = 0,
this.callback,
this.alignment,
});
@override
Widget build(BuildContext context) {
return Container(
alignment: alignment ?? Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < titles.length; i++) _buildMenuButton(i),
],
),
);
}
Widget _buildMenuButton(int index) {
final isLast = index == titles.length - 1;
return InkWell(
enableFeedback: false,
onTap: () => callback?.call(index),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
titles[index],
style: TextStyle(
fontSize: 16.sp,
color: index == selectIndex ? const Color(0xE5FFFFFF) : const Color(0x73FFFFFF),
),
),
//分隔竖线跟在每项后面,最后一项不带
if (!isLast)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
"|",
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.05)),
),
),
],
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_utils/sliver_delegate.dart';
import '../home_sub_module/home_tab_section_logic.dart';
import '../widget/home_sort_menu_view.dart';
import 'video_simple_cell.dart';
/// 亚模块底部的「猜你喜欢」:标题 + 吸顶排序栏 + 两列网格
class GuessLikeSliver extends StatelessWidget {
final AllSection model;
final int? sortIndex;
final HomeTabSectionLogic? logic;
const GuessLikeSliver(this.model, {super.key, this.sortIndex, this.logic});
@override
Widget build(BuildContext context) {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Container(
padding: EdgeInsets.only(left: 12),
child: Text(
"猜你喜欢",
style: TextStyle(
fontSize: 18,
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w600,
),
),
),
),
SliverPersistentHeader(
pinned: true,
floating: true,
delegate: MySliverDelegate(
maxHeight: 42,
minHeight: 42,
childBuildHandler: (_, offset, overlaps, child) {
return Container(
color:
(offset >= 0) ? AppColors.primaryColor : Colors.transparent,
child: child,
);
},
child: Container(
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 12),
child: HomeSortMenuView(
["最多收藏", "最新上架", "最多观看"],
gapChar: "|",
selectIndex: sortIndex ?? 0,
callback: (index) {
if (index != sortIndex) {
logic?.sortGuessLikeExchange(index);
}
},
),
),
),
),
if (model.allVideoInfo == null)
SliverToBoxAdapter(
child: SizedBox(height: 300, child: LoadingCenterWidget()),
)
else if (model.allVideoInfo!.isEmpty)
SliverToBoxAdapter(
child: SizedBox(height: 300, child: CErrorWidget()),
)
else
SliverPadding(
padding: EdgeInsets.only(left: 12, right: 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 8,
childAspectRatio: 168 / 154,
),
delegate: SliverChildBuilderDelegate(
(context, index) =>
VideoSimpleCell(videoModel: model.allVideoInfo![index]),
childCount: model.allVideoInfo!.length,
),
),
),
],
);
}
}
@@ -0,0 +1,490 @@
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_utils/api_service/vid_service.dart';
import '../../../tools_base/debug_log.dart';
import '../special_topic_detail/special_topics_detail_page.dart';
import 'home_section_title.dart';
import 'video_hor_cell.dart';
import 'video_simple_cell.dart';
// OneLargeAndFourSmall = 101 or 0 // 0 一大四小
// HScroll15 = 104 // 1 1.5左右滑动(横)
// HScroll25 = 105 // 2 2.5左右滑动(横)
// FourGrid = 102 // 3 四宫格(横acg+影视共用)
// SixGrid = 103 // 4 六宫格(横)
// HListGrid = 106 // 5 小列表 (横)
// HListBigGrid = 107 // 6 大列表 (横)
// VerticalScroll25 = 205 // 7 2.5左右滑动(竖ACG)
// VerticalFourGrid = 201 // 8 四宫格(竖ACG)
// VerticalSixGrid = 202 // 9 六宫格(竖ACG)
// VerticalNineGrid = 203 // 10 九宫格(竖ACG)
// VerticalListGrid = 204 // 11 小列表 (竖ACG)
// GuessYouLike = 301 // 12 猜你喜欢
enum HomeSectionShowType {
oneLargeAndFourSmall(101, canExchange: true),
hScroll15(104),
hScroll25(105),
fourGrid(102, canExchange: true),
sixGrid(103, canExchange: true),
hListGrid(106),
hListBigGrid(107),
verticalScroll25(205),
verticalFourGrid(201),
verticalSixGrid(202),
verticalNineGrid(203),
verticalListGrid(204),
guessYouLike(301),
;
final int value;
final bool canExchange; //底部是否挂「更多片源 / 更换一批」
const HomeSectionShowType(this.value, {this.canExchange = false});
}
//首页专题 section:按 showType 排版的内容 + (部分样式)底部换一批 + 间距分割线
class HomeSectionCell extends StatefulWidget {
final AllSection section;
const HomeSectionCell(this.section, {super.key});
@override
State<HomeSectionCell> createState() => _HomeSectionCellState();
}
class _HomeSectionCellState extends State<HomeSectionCell> {
//value→枚举 O(1) 查找,避免每次 build 线性遍历 values
static final Map<int?, HomeSectionShowType> _typeMap = {
for (final t in HomeSectionShowType.values) t.value: t,
};
AllSection get section => widget.section;
bool _exchanging = false; //换一批请求中
@override
Widget build(BuildContext context) {
//未知 showType 排版兜底一大四小,但不挂换一批(与原先按 101/102/103 判断保持一致)
final style = _typeMap[section.showType];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_body(style),
//101/102/103 底部挂「更多片源 / 更换一批」,其余样式只留间距
if (style?.canExchange == true)
Padding(
padding: const EdgeInsets.only(bottom: 24), child: _exchangeBar())
else
12.sizeBoxH,
//section 间的分割线跟着 cell 走,外面只管往列表里塞
Container(
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
height: 1,
color: const Color(0x19FFFFFF),
),
],
);
}
Widget _body(HomeSectionShowType? style) {
switch (style) {
case HomeSectionShowType.hScroll15:
return SectionOneHalfFiveHor(section);
case HomeSectionShowType.hScroll25:
return SectionTwoHalfFiveHV(section);
case HomeSectionShowType.fourGrid:
return SectionGridViewHV(section, maxCount: 4);
case HomeSectionShowType.sixGrid:
return SectionGridViewHV(section, maxCount: 6);
case HomeSectionShowType.hListGrid:
return SectionListView(section);
case HomeSectionShowType.hListBigGrid:
return SectionListView(section, isBigStyle: true);
case HomeSectionShowType.verticalScroll25:
return SectionTwoHalfFiveHV(section, isVertical: true);
case HomeSectionShowType.verticalFourGrid:
return SectionGridViewHV(
section,
maxCount: 4,
aspectRatio: 168 / 266,
textLines: 1,
);
case HomeSectionShowType.verticalSixGrid:
return SectionGridViewHV(
section,
maxCount: 6,
aspectRatio: 111 / 190,
crossAxisCount: 3,
textLines: 1,
);
case HomeSectionShowType.verticalNineGrid:
return SectionGridViewHV(
section,
maxCount: 9,
aspectRatio: 111 / 190,
crossAxisCount: 3,
textLines: 1,
);
case HomeSectionShowType.verticalListGrid:
return SectionGridViewSmall(section);
default:
return SectionOneBigFourSmall(section);
}
}
//底部两颗胶囊:更多片源(跳专题详情) / 更换一批(换数据)
Widget _exchangeBar() {
return Container(
height: 36,
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Row(
children: [
Expanded(
child: _ExchangeButton(
title: '更多片源',
onTap: () => Get.to(SpecialTopicsDetailPage(section),
preventDuplicates: false),
),
),
16.sizeBoxW,
Expanded(
child: _ExchangeButton(
title: '更换一批', loading: _exchanging, onTap: _exchange),
),
],
),
);
}
//换一批:只替换本 section 的视频(仅重建本 cell),失败保持原数据不动
Future<void> _exchange() async {
if (_exchanging) return;
final id = section.sectionID;
setState(() => _exchanging = true);
try {
final resp = await VidService.sectionVideoExchange(id);
//请求飞行中列表可能被刷新,cell 按 index 复用后 section 已换人,结果不能再往回写
if (resp.videos?.isNotEmpty == true && section.sectionID == id)
section.allVideoInfo = resp.videos;
} catch (e) {
debugLog(e);
}
if (mounted) setState(() => _exchanging = false);
}
}
//「更多片源 / 更换一批」胶囊按钮
class _ExchangeButton extends StatelessWidget {
final String title;
final bool loading;
final VoidCallback onTap;
const _ExchangeButton(
{required this.title, required this.onTap, this.loading = false});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x1AFFFFFF),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//转圈占位 16、常态 4,保持文字位置不跳
if (loading)
const SizedBox(
width: 16,
height: 16,
child: CupertinoActivityIndicator(
radius: 8, color: Colors.white))
else
const SizedBox(width: 4),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 14,
fontWeight: FontWeight.w400),
),
],
),
),
);
}
}
//一大四小
class SectionOneBigFourSmall extends StatelessWidget {
final AllSection section;
const SectionOneBigFourSmall(this.section, {super.key});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
Container(
height: 235,
padding: EdgeInsets.symmetric(horizontal: 12),
child: VideoSimpleCell(
videoModel: section.allVideoInfo!.first,
textLines: 1,
),
),
12.sizeBoxH,
GridView.builder(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 12),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 168 / 154,
),
itemCount: min(4, section.allVideoInfo!.length - 1),
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index + 1];
return VideoSimpleCell(videoModel: model);
},
),
],
);
}
}
//1.5横
class SectionOneHalfFiveHor extends StatelessWidget {
final AllSection section;
const SectionOneHalfFiveHor(this.section, {super.key});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
Container(
height: 202,
padding: EdgeInsets.only(left: 12),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: section.allVideoInfo!.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
return Container(
height: 202,
width: 280,
margin: EdgeInsets.only(right: 8),
child: VideoSimpleCell(videoModel: model, textLines: 1),
);
},
),
)
],
);
}
}
//2.5横/竖
class SectionTwoHalfFiveHV extends StatelessWidget {
final AllSection section;
final bool isVertical; // true 竖, false 横
const SectionTwoHalfFiveHV(this.section,
{super.key, this.isVertical = false});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
Container(
height: isVertical ? 218 : 146,
padding: EdgeInsets.only(left: 12),
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final width = (constraints.maxWidth - 2 * 8) / 2.5;
return SizedBox(
height: isVertical ? 218 : 146,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: section.allVideoInfo!.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
return Container(
height: 146,
width: width,
margin: EdgeInsets.only(right: 8),
child: VideoSimpleCell(
videoModel: model,
textLines: isVertical ? 1 : 2,
),
);
},
),
);
},
),
)
],
);
}
}
//四/六/九宫格横/竖 (默认四宫格)
class SectionGridViewHV extends StatelessWidget {
final AllSection section;
final int maxCount;
final double aspectRatio;
final int crossAxisCount;
final int textLines;
const SectionGridViewHV(
this.section, {
super.key,
this.maxCount = 4,
this.aspectRatio = 168 / 154,
this.crossAxisCount = 2,
this.textLines = 2,
});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
GridView.builder(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 12),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: aspectRatio,
),
itemCount: min(maxCount, section.allVideoInfo!.length),
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
return VideoSimpleCell(videoModel: model, textLines: textLines);
},
),
],
);
}
}
//列表横(大小横)
class SectionListView extends StatelessWidget {
final AllSection section;
final bool isBigStyle; // true 大横, false 小横
const SectionListView(this.section, {super.key, this.isBigStyle = false});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
ListView.separated(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 12),
shrinkWrap: true,
itemCount: min(3, section.allVideoInfo!.length),
separatorBuilder: (context, index) => 12.sizeBoxH,
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
if (isBigStyle) {
return SizedBox(
height: 230,
child: VideoSimpleCell(videoModel: model, textLines: 1),
);
} else {
return SizedBox(
height: 96,
child: VideoHorCell(videoModel: model),
);
}
},
),
],
);
}
}
//小列表(竖)
class SectionGridViewSmall extends StatelessWidget {
final AllSection section;
const SectionGridViewSmall(this.section, {super.key});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
Container(
padding: EdgeInsets.only(left: 12),
height: 232,
child: GridView.builder(
padding: EdgeInsets.zero,
scrollDirection: Axis.horizontal,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 16,
crossAxisSpacing: 8,
childAspectRatio: 110 / 256,
),
itemCount: section.allVideoInfo!.length,
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
return VideoHorCell(
videoModel: model,
imageWidth: 82,
);
},
),
),
],
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../special_topic_detail/special_topics_detail_page.dart';
/// 专题标题行:标题 + 右箭头,点整行进专题详情
class HomeSectionTitle extends StatelessWidget {
final AllSection? section;
const HomeSectionTitle(this.section, {super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(12, 0, 12, 12),
child: GestureDetector(
onTap: () =>
Get.to(SpecialTopicsDetailPage(section), preventDuplicates: false),
child: Row(
children: [
Expanded(
child: Text(
section?.sectionName ?? "",
maxLines: 1,
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
12.sizeBoxW,
Image.asset("arrow_right_grey.webp".commonImgPath, width: 18),
],
),
),
);
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../config/config.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/free_play_manager.dart';
/// 封面右上角的售卖角标:免费试看 / 金币 / VIP,三者互斥,都不满足就不占位。
/// 展不展示还受 ping 下发的 freeMark / coinMark / vipMark 三个开关控制
class LevelMarkChip extends StatelessWidget {
final VideoModel? videoModel;
const LevelMarkChip(this.videoModel, {super.key});
@override
Widget build(BuildContext context) {
if (videoModel?.freeArea == true) return const SizedBox();
// 免费试看:ping 免费类角标总开关 freeMark + 实时试看权益(次数用尽即不再展示)
if (Config.freeMark && FreePlayManager().canShowFreeTrialBadge(videoModel)) {
return _chip("免费试看", const Color(0xff141414), const [Color(0xff35DEBC), Color(0xff22BB9C)]);
}
final isCoin = videoModel?.isCoinVideo() == true;
if (isCoin && Config.coinMark) {
return _chip("金币", const Color(0xff8B3E00), const [Color(0xffFFE580), Color(0xffFACC15)]);
}
if (!isCoin && Config.vipMark) {
return _chip("VIP", const Color(0xff141414), const [Color(0xffF8F3AE), Color(0xffDDAC44)]);
}
return const SizedBox();
}
Widget _chip(String text, Color textColor, List<Color> gradientColors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
gradient: LinearGradient(colors: gradientColors),
),
child: Text(
text,
style: TextStyle(color: textColor, fontSize: 10, height: 1.6),
),
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../hj_model/home/plate_model.dart';
import '../../../hj_model/splash/domain_source_model.dart';
/// 金刚区:亚模块顶部那排图标入口,横向可滑,一屏露 5.5 个。
/// 配置来自 /domain 下发的 jgArea,按 mid == 亚模块 id 匹配;没配就整块不占位
class QuickEntryRow extends StatelessWidget {
final ModuleData? tabModel;
const QuickEntryRow(this.tabModel, {super.key});
@override
Widget build(BuildContext context) {
final dataArr =
(Config.jgArea ?? []).where((e) => e.mid == tabModel?.id).toList();
if (dataArr.isEmpty) return const SizedBox.shrink();
return Container(
height: 62,
margin: const EdgeInsets.fromLTRB(12, 0, 0, 12),
child: LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 5 * 16) / 5.5;
final height = width * 48 / 58;
return SizedBox(
height: height,
child: ListView.builder(
itemCount: dataArr.length,
padding: EdgeInsets.zero,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) =>
_buildItem(dataArr[index], width),
),
);
},
),
);
}
Widget _buildItem(JGAreaModel model, double width) {
return Container(
height: 62,
width: width,
margin: const EdgeInsets.only(right: 16),
child: InkWell(
enableFeedback: false,
onTap: () => pushToPageByLink(model.linkUrl),
child: Column(
children: [
Expanded(
child: Container(
alignment: Alignment.center,
child: AspectRatio(
aspectRatio: 1,
child: NetworkImageLoader(imageUrl: model.img ?? ""),
),
),
),
3.sizeBoxH,
Text(
model.name ?? "",
maxLines: 1,
style: const TextStyle(color: Colors.white, fontSize: 11),
),
],
),
),
);
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import '../../../hj_model/video_model.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'package:hgdj/extension/extensions.dart';
/// 抖音样式的视频 item:封面 + 底部角标 + 标题 +(可选)标签/评论数。
/// 封面占满父级剩余高度,外层必须给约束(网格 / 固定高容器)
class TiktokSimpleCell extends StatelessWidget {
final VideoModel? videoModel;
final int textLines;
final bool isShowBottom; // true 显示底部标签和评论数
final bool isShowTime; // true 封面右下角显示时长,false 显示图集数
const TiktokSimpleCell({
super.key,
this.videoModel,
this.textLines = 2,
this.isShowTime = false,
this.isShowBottom = true,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: _buildCover()),
Container(
padding: const EdgeInsets.only(top: 4),
//末尾补个换行,让不满两行的标题也占满高度,卡片不会参差
child: Text(
"${videoModel?.title ?? ""}\n",
maxLines: textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9), fontSize: 12),
),
),
if (isShowBottom) _buildBottom(),
],
),
);
}
void _onTap() {
if (videoModel?.isRandomAd() == true) {
pushToPageByLink(videoModel?.randomAdsInfo?.href);
return;
}
pushToVideoPage(videoModel: videoModel);
}
//封面:图 + 底部渐变角标条
Widget _buildCover() {
return Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(imageUrl: videoModel?.cover ?? "", borderRadius: 4),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(4)),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: 0.6)
],
),
),
child: _buildCoverBadge(),
),
),
],
);
}
//角标条:左播放数,右边给时长或图集数
Widget _buildCoverBadge() {
return Row(
children: [
Image.asset("eye_white.webp".commonImgPath, width: 16),
3.sizeBoxW,
Text(
videoModel?.playCount?.countStr ?? "0",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: Colors.white, fontSize: 10, height: 1.6),
),
const Spacer(),
if (isShowTime)
Text(
videoModel?.playTime?.hmsStr ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 10),
)
else ...[
Image.asset("tuji_icon.webp".communityPath, width: 16),
2.sizeBoxW,
Text(
'${videoModel?.seriesCover?.length ?? 0}',
textAlign: TextAlign.justify,
style: const TextStyle(color: Colors.white, fontSize: 10),
),
],
2.sizeBoxW,
],
);
}
//标签 + 评论数
Widget _buildBottom() {
final tagName = videoModel?.tags?.firstOrNull?.name ?? "";
return Container(
padding: const EdgeInsets.only(top: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
//标签可能是很长的脏数据(id)Flexible 才能让 ellipsis 生效,否则会撑爆 Row
Flexible(
child: tagName.isEmpty
? const SizedBox.shrink()
: Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: const Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
tagName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0x73FFFFFF), fontSize: 10),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
"评论${videoModel?.commentCount?.countStr ?? 0}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0x73FFFFFF), fontSize: 10),
),
),
],
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/free_play_manager.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'level_mark_chip.dart';
import 'package:hgdj/extension/extensions.dart';
/// 横版视频 item:左封面(带售卖角标) + 右标题/收藏数,用在专题的小列表样式
class VideoHorCell extends StatelessWidget {
final VideoModel? videoModel;
final double imageWidth;
const VideoHorCell({super.key, this.videoModel, this.imageWidth = 150});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: Row(
children: [
Stack(
children: [
NetworkImageLoader(
width: imageWidth,
height: double.infinity,
borderRadius: 4,
imageUrl: videoModel?.cover ?? "",
),
Positioned(
right: 6,
top: 6,
// 监听免费次数变化:会话内试看用尽时角标实时刷新,不再残留「免费试看」
child: ValueListenableBuilder<int>(
valueListenable: FreePlayManager().revision,
builder: (_, __, ___) => LevelMarkChip(videoModel),
),
),
],
),
12.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${videoModel?.title ?? ""}\n",
maxLines: 2,
style: TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.2,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
4.sizeBoxH,
Text(
"收藏:${videoModel?.collectCount?.countStr ?? "0"}",
maxLines: 2,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 12,
),
),
],
),
),
],
),
);
}
void _onTap() {
if (videoModel?.isRandomAd() == true) {
pushToPageByLink(videoModel?.randomAdsInfo?.href);
return;
}
pushToVideoPage(videoModel: videoModel);
}
}
@@ -0,0 +1,471 @@
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/banner/ads_item.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import '../../../hj_model/splash/ads_model.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/free_play_manager.dart';
import '../../../tools_base/banner/ads_banner_widget.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'level_mark_chip.dart';
/// 竖版视频 item:封面(角标/售卖标/暗网遮罩) + 标题 +(可选)标签/评论数。
/// 命中广告数据时整体换成 [VideoSimpleAdsCell]
class VideoSimpleCell extends StatelessWidget {
final VideoModel? videoModel;
final BorderRadius? imgBorderRadius;
final GestureTapCallback? onTap;
final int textLines;
final bool isFromHY; // 黄油风格
final bool isShowBottom; // true 显示底部标签和评论数
final bool isFromSearch;
final double titleFontSize; // 标题字号,小卡场景传小值
final double coverFontSize; // 封面底部播放量/时长字号,小卡场景传小值避免溢出
final String? coverRightText; // 封面右下角文案,默认时长;短剧传「共N集」
final bool showLevelIcon; // 封面右上角「金币/VIP/免费试看」角标;短剧按集卖,整部剧标一个对不上,传 false
final ValueChanged<TagsBean>? onTagTap; // 左下角标签点击;短剧跳标签详情
const VideoSimpleCell({
super.key,
this.videoModel,
this.imgBorderRadius,
this.textLines = 2,
this.onTap,
this.isFromHY = false,
this.isShowBottom = true,
this.isFromSearch = false,
this.titleFontSize = 12,
this.coverFontSize = 10,
this.coverRightText,
this.showLevelIcon = true,
this.onTagTap,
});
// 暗网风格遮罩:搜索来源且命中暗标签
bool get isDarkStyle => isFromSearch && (videoModel?.isDarkTag ?? false);
// 暗网内容且非 VIP,需引导开通才能观看
bool get _needVipUnlock => isDarkStyle && !globalStore.isAWVIP;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap ?? _onTap,
child: videoModel?.isAdsArr() == true
? VideoSimpleAdsCell(
videoModel: videoModel,
textLines: textLines,
)
: _buildContent(),
);
}
void _onTap() {
if (videoModel?.isRandomAd() == true) {
pushToPageByLink(videoModel?.randomAdsInfo?.href);
return;
}
if (_needVipUnlock) {
pushToWalletPage(vipId: Config.darkWebVipId);
return;
}
pushToVideoPage(videoModel: videoModel);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: _buildCoverItem()),
Container(
padding: const EdgeInsets.only(top: 4),
//末尾补个换行,让不满两行的标题也占满高度,卡片不会参差
child: Text(
"${videoModel?.title ?? ""}\n",
maxLines: textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: titleFontSize),
),
),
if (isShowBottom) _buildBottomWidget(),
],
);
}
Widget _buildCoverItem() {
return Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(
imageUrl: videoModel?.cover ?? "",
imgBorderRadius: imgBorderRadius,
borderRadius: 4,
blur: _needVipUnlock,
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 24,
padding: EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(4)),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: 0.6),
],
),
),
child: isFromHY ? _buildCoverHYBottom() : _buildCoverNormalBottom(),
),
),
if (showLevelIcon)
Positioned(
right: 6,
top: 6,
// 监听免费次数变化:会话内试看用尽时角标实时刷新,不再残留「免费试看」
child: ValueListenableBuilder<int>(
valueListenable: FreePlayManager().revision,
builder: (_, __, ___) => LevelMarkChip(videoModel),
),
),
if (_needVipUnlock)
Container(
color: Colors.black.withValues(alpha: 0.1),
alignment: Alignment.center,
padding: EdgeInsets.only(bottom: 8),
child: EasyRichText(
'需要开通 ${Config.darkWebVipName}\n即可观看',
textAlign: TextAlign.center,
defaultStyle: TextStyle(
fontSize: 10,
color: Colors.white,
height: 1.6,
),
patternList: [
EasyRichTextPattern(
targetString: Config.darkWebVipName,
style: TextStyle(color: Color(0xffF68804)),
),
],
),
),
],
);
}
Widget _buildCoverNormalBottom() {
return Row(
children: [
Image.asset('circle_play.webp'.videoPath, width: 16),
1.sizeBoxW,
Text(
videoModel?.playCount?.countStr ?? "0",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white, fontSize: coverFontSize, height: 1.6),
),
Spacer(),
Text(
coverRightText ?? videoModel?.playTime?.hmsStr ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: coverFontSize,
),
),
],
);
}
Widget _buildCoverHYBottom() {
return Row(
children: [
Image.asset("eye_white.webp".commonImgPath, width: 16),
1.sizeBoxW,
Text(
videoModel?.pageViewCount?.countStr ?? "0",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white, fontSize: 10, height: 1.6),
),
Spacer(),
],
);
}
Widget _buildBottomWidget() {
if (isFromHY) {
final tagDesc =
videoModel?.tags?.map((e) => e.name ?? "").join("·") ?? "";
return Container(
padding: EdgeInsets.only(top: 3),
child: Text(
tagDesc,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 10,
),
),
);
} else {
return Container(
padding: EdgeInsets.only(top: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
//标签可能是很长的脏数据(id)Flexible 才能让 ellipsis 生效,否则会撑爆 Row
Flexible(
child: videoModel?.tags?.isNotEmpty == true
? GestureDetector(
onTap: onTagTap == null
? null
: () => onTagTap!(videoModel!.tags!.first),
child: Container(
padding:
EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
videoModel?.tags?.first.name ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 10,
),
),
),
)
: const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
"评论${videoModel?.commentCount?.countStr ?? 0}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 10,
),
),
),
],
),
);
}
}
}
//视频穿插广告
class VideoSimpleAdsCell extends StatefulWidget {
final VideoModel? videoModel;
final int textLines;
const VideoSimpleAdsCell({
super.key,
this.videoModel,
this.textLines = 2,
});
@override
State<VideoSimpleAdsCell> createState() => _VideoSimpleAdsCellState();
}
class _VideoSimpleAdsCellState extends State<VideoSimpleAdsCell> {
VideoModel? get videoModel => widget.videoModel;
int adIndex = 0;
List<AdsInfoModel>? get adsInfoArr =>
videoModel?.adsInfoArr; //?? mediaInfo?.adsInfoArr;
AdsInfoModel? get adsModel => videoModel?.randomAdsInfo;
// 广告点击:跳落地页
void _onAdTap() {
final ad = adsInfoArr![adIndex];
pushToPageByLink(ad.href);
}
@override
Widget build(BuildContext context) {
if (adsModel != null) {
return _buildSimpleAD(adsModel);
} else if (adsInfoArr?.isNotEmpty == true) {
return _buildMuliAds();
} else {
return SizedBox();
}
}
Widget _buildMuliAds() {
return InkWell(
enableFeedback: false,
onTap: _onAdTap,
child: LayoutBuilder(
builder: (ctx, constraints) {
// isHor true: 大横屏广告
bool isHor = (screen.screenWidth - 36) <= constraints.maxWidth;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AdsBannerWidget(
adsInfoArr,
autoPlayMs: 3000,
borderRadius: 4,
onIndexChanged: (index) {
adIndex = index;
if (mounted) setState(() {});
},
),
),
),
Container(
padding: EdgeInsets.only(top: 4),
// 广告轮播切到下一条时,标题做渐入渐出切换
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
layoutBuilder: (current, previous) => Stack(
alignment: Alignment.centerLeft,
children: [...previous, if (current != null) current],
),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: Text(
isHor
? adsInfoArr![adIndex].title?.trim() ?? ""
: "${adsInfoArr![adIndex].title?.trim() ?? ""}\n",
key: ValueKey(adIndex), // adIndex 变化触发切换动画
maxLines: widget.textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
),
if (!isHor)
Container(
padding: const EdgeInsets.only(top: 3),
child: Text(
"",
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
);
},
),
);
}
Widget _buildSimpleAD(AdsInfoModel? adModel) {
return InkWell(
enableFeedback: false,
onTap: _onAdTap,
child: LayoutBuilder(
builder: (ctx, constraints) {
// isHor true: 大横屏广告
bool isHor = (screen.screenWidth - 32) <= constraints.maxWidth;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
AdsItem(
adInfo: adModel!,
borderRadius: 4,
showType: AdShowType.img,
),
Positioned(
top: 0,
left: 0,
child: Container(
width: 34,
height: 16,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [
Color.fromRGBO(255, 235, 58, 1),
Color.fromRGBO(255, 235, 58, 1),
]),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4),
)),
child: Text(
"广告",
style: TextStyle(
fontSize: 11,
color: Color.fromRGBO(0, 0, 0, 1),
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
Container(
padding: const EdgeInsets.only(top: 4),
child: Text(
isHor
? videoModel?.title ?? ""
: "${videoModel?.title ?? ""}\n",
maxLines: widget.textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
if (!isHor)
Container(
padding: const EdgeInsets.only(top: 3),
child: Text(
"",
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
);
},
),
);
}
}