feat: 视频详情页,分类页。个人中心
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user