feat: 视频详情页,分类页。个人中心

This commit is contained in:
chengsiwen committed 2026-07-07 16:04:15 +08:00
1 parent b5bce6edcf
commit a3426831ed
26 files changed
+2878 -1077

No files matched your search

@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/model/ny_detail_model.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/common/model/ny_episode_model.dart';
import 'package:novyronst/pages/first/player_detail/video_esp_page.dart';
import 'package:novyronst/pages/play/widgets/player_view.dart';
class VideoDetailPage extends StatefulWidget {
const VideoDetailPage({super.key, required this.drama});
final NyDramaModel drama;
@override
State<VideoDetailPage> createState() => _VideoDetailPageState();
}
class _VideoDetailPageState extends State<VideoDetailPage> {
NyDetailModel? _detail;
List<NyDramaModel> _videos = [];
int _currentIndex = 0;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadDetail();
}
Future<void> _loadDetail() async {
setState(() {
_isLoading = true;
});
final detail = await VideoRequest.getVideoDetail(
shortPlayId: widget.drama.shortPlayId ?? widget.drama.shortId,
shortPlayVideoId:
widget.drama.shortPlayVideoId ??
widget.drama.id ??
widget.drama.shortId,
);
if (!mounted) return;
final videos = detail == null
? [widget.drama]
: buildEpisodeDramaList(detail);
final nextIndex = detail == null
? 0
: episodeIndexForDrama(detail.episodeList, widget.drama);
setState(() {
_detail = detail;
_videos = videos;
_currentIndex = nextIndex < 0 ? 0 : nextIndex;
_isLoading = false;
});
}
Future<void> _toggleCollect(int index) async {
if (index < 0 || index >= _videos.length) return;
final item = _videos[index];
final videoId = item.shortPlayVideoId ?? item.id;
if (videoId == null) return;
final shortPlayId = item.shortPlayId ?? item.shortId;
final oldIsCollect = item.isCollect ?? false;
final oldCollectTotal = item.collectTotal;
final newIsCollect = !oldIsCollect;
final newCollectTotal = oldCollectTotal + (newIsCollect ? 1 : -1);
setState(() {
_videos[index] = item.copyWith(
isCollect: newIsCollect,
collectTotal: newCollectTotal < 0 ? 0 : newCollectTotal,
);
});
final success = newIsCollect
? await VideoRequest.collect(shortPlayId: shortPlayId, videoId: videoId)
: await VideoRequest.cancelCollect(shortPlayId: shortPlayId);
if (!mounted || success) return;
setState(() {
_videos[index] = _videos[index].copyWith(
isCollect: oldIsCollect,
collectTotal: oldCollectTotal,
);
});
}
void _showEpisodeSheet() {
final detail = _detail;
if (detail == null || detail.episodeList.isEmpty) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return VideoEspPage(
detail: detail,
currentEpisode: _currentEpisode,
onSelectedEpisode: (episode) {
Navigator.pop(context);
final nextIndex = episodeIndexForEpisode(
detail.episodeList,
episode,
);
if (nextIndex < 0) return;
setState(() {
_currentIndex = nextIndex;
});
},
);
},
);
}
int get _currentEpisode {
if (_videos.isEmpty || _currentIndex >= _videos.length) return 1;
return _videos[_currentIndex].episode ?? _currentIndex + 1;
}
int get _totalEpisode {
final detail = _detail;
final total =
detail?.shortPlayInfo?.episodeTotal ?? widget.drama.episodeTotal;
if (total > 0) return total;
return detail?.episodeList.length ?? _videos.length;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(child: _buildBody()),
Positioned(
left: 10,
top: MediaQuery.paddingOf(context).top + 8,
child: IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
),
),
],
),
);
}
Widget _buildBody() {
if (_isLoading && _videos.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_videos.isEmpty) {
return const Center(
child: Text('No videos', style: TextStyle(color: Colors.white70)),
);
}
return PlayerView(
videos: _videos,
initialIndex: _currentIndex,
bottomMetaSpacing: 24,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
onCollectTap: _toggleCollect,
progressHeaderBuilder: (context, drama, index) {
return _EpisodeSelectorBar(
currentEpisode: drama.episode ?? index + 1,
totalEpisode: _totalEpisode,
onTap: _showEpisodeSheet,
);
},
);
}
}
class _EpisodeSelectorBar extends StatelessWidget {
const _EpisodeSelectorBar({
required this.currentEpisode,
required this.totalEpisode,
required this.onTap,
});
final int currentEpisode;
final int totalEpisode;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const Icon(
Icons.video_library_outlined,
color: Colors.white,
size: 17,
),
const SizedBox(width: 10),
Text(
'EP.$currentEpisode',
style: const TextStyle(color: Colors.white, fontSize: 13),
),
Text(
' / EP.$totalEpisode',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 13,
),
),
const Spacer(),
const Icon(Icons.arrow_forward_ios, color: Colors.white, size: 13),
],
),
),
);
}
}
List<NyDramaModel> buildEpisodeDramaList(NyDetailModel detail) {
final drama = detail.shortPlayInfo ?? detail.videoInfo;
final info = detail.videoInfo ?? detail.shortPlayInfo;
final total = drama?.episodeTotal ?? detail.episodeList.length;
return detail.episodeList.map((episode) {
return NyDramaModel(
id: episode.id,
shortId: episode.shortId,
shortPlayId: episode.shortPlayId,
name: drama?.name ?? info?.name ?? '',
description: drama?.description ?? info?.description ?? '',
imageUrl: drama?.imageUrl ?? info?.imageUrl ?? '',
horizontallyImg: drama?.horizontallyImg ?? info?.horizontallyImg ?? '',
collectTotal: drama?.collectTotal ?? info?.collectTotal ?? 0,
watchTotal: drama?.watchTotal ?? info?.watchTotal ?? 0,
episodeTotal: total,
isCollect: detail.isCollect ?? drama?.isCollect ?? info?.isCollect,
videoUrl: episode.videoUrl,
videoInfo: {'video_url': episode.videoUrl},
tagType: drama?.tagType ?? info?.tagType ?? '',
episode: episode.episode,
shortPlayVideoId: episode.shortPlayVideoId,
currentEpisode: episode.episode,
);
}).toList();
}
int episodeIndexForEpisode(
List<NyEpisodeModel> episodes,
NyEpisodeModel selectedEpisode,
) {
return episodes.indexWhere(
(episode) => episode.shortPlayVideoId == selectedEpisode.shortPlayVideoId,
);
}
int episodeIndexForDrama(List<NyEpisodeModel> episodes, NyDramaModel drama) {
final videoId = drama.shortPlayVideoId ?? drama.id;
if (videoId != null) {
final index = episodes.indexWhere(
(episode) => episode.shortPlayVideoId == videoId,
);
if (index >= 0) return index;
}
final episodeNumber = drama.episode ?? drama.currentEpisode;
if (episodeNumber != null) {
return episodes.indexWhere((episode) => episode.episode == episodeNumber);
}
return -1;
}
@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/common/help/help_image.dart';
import 'package:novyronst/common/model/ny_detail_model.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/common/model/ny_episode_model.dart';
import 'package:novyronst/tools/util/app_screen.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
class VideoEspPage extends StatefulWidget {
const VideoEspPage({
super.key,
required this.detail,
this.currentEpisode,
this.onSelectedEpisode,
});
final NyDetailModel detail;
final int? currentEpisode;
final ValueChanged<NyEpisodeModel>? onSelectedEpisode;
@override
State<VideoEspPage> createState() => _VideoEspPageState();
}
class _VideoEspPageState extends State<VideoEspPage> {
late int _selectedEpisode;
NyDramaModel? get _drama =>
widget.detail.shortPlayInfo ?? widget.detail.videoInfo;
NyDramaModel? get _info => widget.detail.videoInfo;
List<NyEpisodeModel> get _episodes => widget.detail.episodeList;
@override
void initState() {
super.initState();
_selectedEpisode =
widget.currentEpisode ?? _drama?.currentEpisode ?? _drama?.episode ?? 1;
}
@override
Widget build(BuildContext context) {
const height = 520.0;
return Container(
width: AppScreen.screenWidth,
height: height,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDramaInfo(),
const SizedBox(height: 12),
Divider(
height: 1,
thickness: 0.5,
color: Colors.black.withValues(alpha: 0.08),
),
const SizedBox(height: 12),
NovyText(
'$_episodeTotal EP',
color: Colors.black,
fontSize: 15,
fontWeight: FontWeight.w500,
),
const SizedBox(height: 8),
Expanded(child: _buildEpisodeGrid()),
],
),
),
),
);
}
Widget _buildDramaInfo() {
final drama = _drama;
final imageUrl = drama?.imageUrl ?? '';
return SizedBox(
height: 130,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HelpImage.networkRadius(
imageUrl,
width: 96,
height: 130,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(2),
),
const SizedBox(width: 16),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 7),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NovyText(
drama?.name ?? '',
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w700,
maxLines: 2,
lineHeight: 1.12,
),
const SizedBox(height: 10),
NovyText(
drama?.description ?? _info?.description ?? '',
color: Colors.black.withValues(alpha: 0.46),
fontSize: 14,
maxLines: 2,
lineHeight: 1.45,
),
const SizedBox(height: 13),
_buildTag(),
],
),
),
),
],
),
);
}
Widget _buildTag() {
final categoryList = _drama?.categoryList;
final name = categoryList?.isNotEmpty == true
? categoryList!.first.name
: (_drama?.tagType ?? '');
if (name.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFE7FFE5),
border: Border.all(color: const Color(0xFF86D57A), width: 1),
),
child: NovyText(
name,
color: Colors.black,
fontSize: 14,
overflow: TextOverflow.visible,
),
);
}
Widget _buildEpisodeGrid() {
if (_episodes.isEmpty) return const SizedBox.shrink();
return GridView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemCount: _episodes.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 61 / 50,
),
itemBuilder: (context, index) {
return _buildEpisodeItem(_episodes[index]);
},
);
}
Widget _buildEpisodeItem(NyEpisodeModel episode) {
final selected = episode.episode == _selectedEpisode;
final selectedPlayable = selected && !episode.isLock;
final selectedLocked = selected && episode.isLock;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_selectedEpisode == episode.episode) return;
setState(() {
_selectedEpisode = episode.episode;
});
widget.onSelectedEpisode?.call(episode);
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: selectedPlayable
? ConstColor.btGreen
: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selectedPlayable
? Colors.black
: (selectedLocked ? ConstColor.btGreen : Colors.transparent),
width: selected ? 1 : 0,
),
),
child: NovyText(
episode.episode.toString(),
color: Colors.black.withValues(alpha: 0.86),
fontSize: 20,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
),
),
);
}
int get _episodeTotal {
final total = _drama?.episodeTotal ?? 0;
return total > 0 ? total : _episodes.length;
}
}