2025-08-30 16:49:21 +08:00
|
|
|
import 'dart:async';
|
|
|
|
import 'dart:convert';
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
|
|
import 'package:get/get.dart';
|
|
|
|
import 'package:loopin/IM/controller/chat_controller.dart';
|
2025-09-09 10:57:52 +08:00
|
|
|
import 'package:loopin/IM/controller/im_user_info_controller.dart';
|
2025-08-30 16:49:21 +08:00
|
|
|
import 'package:loopin/IM/im_core.dart';
|
|
|
|
import 'package:loopin/IM/im_message.dart';
|
|
|
|
import 'package:loopin/IM/im_service.dart' hide logger;
|
|
|
|
import 'package:loopin/api/video_api.dart';
|
|
|
|
import 'package:loopin/components/my_toast.dart';
|
|
|
|
import 'package:loopin/components/network_or_asset_image.dart';
|
2025-09-09 10:57:52 +08:00
|
|
|
import 'package:loopin/models/share_type.dart';
|
2025-08-30 16:49:21 +08:00
|
|
|
import 'package:loopin/models/summary_type.dart';
|
|
|
|
import 'package:loopin/service/http.dart';
|
|
|
|
import 'package:loopin/utils/download_video.dart';
|
|
|
|
import 'package:loopin/utils/permissions.dart';
|
|
|
|
import 'package:loopin/utils/wxsdk.dart';
|
|
|
|
import 'package:media_kit/media_kit.dart';
|
|
|
|
import 'package:media_kit_video/media_kit_video.dart';
|
|
|
|
import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart';
|
|
|
|
import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart';
|
2025-09-04 22:19:56 +08:00
|
|
|
|
2025-08-30 16:49:21 +08:00
|
|
|
import '../../../router/fade_route.dart';
|
|
|
|
import './components/popup_reply.dart';
|
|
|
|
|
|
|
|
class VideoDetailPage extends StatefulWidget {
|
|
|
|
const VideoDetailPage({super.key});
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<VideoDetailPage> createState() => _VideoDetailPageState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _VideoDetailPageState extends State<VideoDetailPage> {
|
|
|
|
final ChatController chatController = Get.find<ChatController>();
|
|
|
|
|
|
|
|
// 播放器controller
|
|
|
|
late Player player = Player();
|
|
|
|
late VideoController videoController = VideoController(player);
|
|
|
|
|
|
|
|
final List<StreamSubscription> subscriptions = [];
|
|
|
|
// 进度条slider当前阈值
|
|
|
|
double sliderValue = 0.0;
|
|
|
|
bool sliderDraging = false;
|
|
|
|
late Duration position = Duration.zero; // 当前时长
|
|
|
|
late Duration duration = Duration.zero; // 总时长
|
|
|
|
|
|
|
|
// 视频数据
|
|
|
|
late Map<String, dynamic> videoData = {};
|
|
|
|
bool isLoading = true;
|
|
|
|
String? errorMessage;
|
|
|
|
|
|
|
|
// 视频ID参数
|
|
|
|
late String videoId;
|
|
|
|
|
|
|
|
// 评论相关状态
|
|
|
|
int commentPage = 1;
|
|
|
|
final int commentPageSize = 10;
|
|
|
|
bool isLoadingMoreComments = false;
|
|
|
|
bool hasMoreComments = true;
|
|
|
|
List<Map<String, dynamic>> commentList = [];
|
|
|
|
String replyingCommentId = ''; // 正在回复的评论ID
|
|
|
|
String replyingCommentUser = ''; // 正在回复的用户名
|
|
|
|
|
|
|
|
// 分享列表
|
|
|
|
List shareList = [
|
|
|
|
{'icon': 'assets/images/share-wx.png', 'label': '好友'},
|
|
|
|
{'icon': 'assets/images/share-pyq.png', 'label': '朋友圈'},
|
|
|
|
{'icon': 'assets/images/share-link.png', 'label': '复制链接'},
|
|
|
|
{'icon': 'assets/images/share-download.png', 'label': '下载'},
|
|
|
|
];
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
|
|
|
|
// 获取路由参数中的videoId
|
|
|
|
final arguments = Get.arguments;
|
|
|
|
if (arguments != null && arguments is Map<String, dynamic>) {
|
|
|
|
videoId = arguments['videoId']?.toString() ?? '';
|
|
|
|
} else {
|
|
|
|
videoId = '';
|
|
|
|
}
|
|
|
|
|
|
|
|
if (videoId.isEmpty) {
|
|
|
|
setState(() {
|
|
|
|
isLoading = false;
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 获取视频详情数据
|
|
|
|
fetchVideoDetail();
|
|
|
|
}
|
|
|
|
|
|
|
|
// 获取视频详情
|
|
|
|
Future<void> fetchVideoDetail() async {
|
|
|
|
setState(() {
|
|
|
|
isLoading = true;
|
|
|
|
});
|
|
|
|
|
|
|
|
try {
|
2025-09-04 22:19:56 +08:00
|
|
|
final res = await Http.get('${VideoApi.videoDetailApi}$videoId');
|
2025-08-30 16:49:21 +08:00
|
|
|
|
|
|
|
logger.d('视频详情接口返回: ${json.encode(res)}');
|
|
|
|
|
|
|
|
if (res['code'] == 200 && res['data'] != null) {
|
|
|
|
setState(() {
|
|
|
|
videoData = Map<String, dynamic>.from(res['data']);
|
|
|
|
isLoading = false;
|
|
|
|
});
|
|
|
|
|
|
|
|
// 初始化播放器并自动播放
|
|
|
|
if (videoData['url'] != null && videoData['url'].toString().isNotEmpty) {
|
|
|
|
await player.open(
|
|
|
|
Media(videoData['url']),
|
|
|
|
play: true, // 自动播放
|
|
|
|
);
|
|
|
|
player.setPlaylistMode(PlaylistMode.loop); // 循环播放
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
setState(() {
|
|
|
|
isLoading = false;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
logger.e('获取视频详情异常: $e');
|
|
|
|
setState(() {
|
|
|
|
isLoading = false;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void didChangeDependencies() {
|
|
|
|
super.didChangeDependencies();
|
|
|
|
if (subscriptions.isEmpty) {
|
|
|
|
subscriptions.addAll(
|
|
|
|
[
|
|
|
|
// 监听视频时长
|
|
|
|
player.stream.duration.listen((event) {
|
|
|
|
setState(() {
|
|
|
|
duration = event;
|
|
|
|
});
|
|
|
|
}),
|
|
|
|
// 监听视频播放进度
|
|
|
|
player.stream.position.listen((event) {
|
|
|
|
setState(() {
|
|
|
|
position = event;
|
|
|
|
if (position > Duration.zero && !sliderDraging) {
|
|
|
|
// 设置视频播放位置
|
|
|
|
sliderValue = (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
player.dispose();
|
|
|
|
for (final subscription in subscriptions) {
|
|
|
|
subscription.cancel();
|
|
|
|
}
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
// 取消喜欢视频
|
|
|
|
Future<void> doUnLikeVideo() async {
|
|
|
|
logger.d('点击了取消点赞按钮');
|
|
|
|
try {
|
|
|
|
final res = await Http.post(VideoApi.unlike, data: {'id': videoData['id']});
|
|
|
|
final resCode = res['code'];
|
|
|
|
if (resCode == 200) {
|
|
|
|
setState(() {
|
|
|
|
videoData['doILikeThisVlog'] = false;
|
|
|
|
videoData['likeCounts'] = (videoData['likeCounts'] ?? 1) - 1;
|
|
|
|
});
|
|
|
|
MyToast().tip(
|
|
|
|
title: '已取消点赞',
|
|
|
|
position: 'center',
|
|
|
|
type: 'success',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
logger.i('点击取消喜欢按钮报错: $e');
|
|
|
|
MyToast().tip(
|
|
|
|
title: '操作失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 点击喜欢视频
|
|
|
|
Future<void> doLikeVideo() async {
|
|
|
|
try {
|
|
|
|
final res = await Http.post(VideoApi.like, data: {'id': videoData['id']});
|
|
|
|
final resCode = res['code'];
|
|
|
|
if (resCode == 200) {
|
|
|
|
setState(() {
|
|
|
|
videoData['doILikeThisVlog'] = true;
|
|
|
|
videoData['likeCounts'] = (videoData['likeCounts'] ?? 0) + 1;
|
|
|
|
});
|
|
|
|
MyToast().tip(
|
|
|
|
title: '点赞成功',
|
|
|
|
position: 'center',
|
|
|
|
type: 'success',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
logger.i('点赞返回信息----------->: $res');
|
|
|
|
} catch (e) {
|
|
|
|
logger.i('点击喜欢按钮报错: $e');
|
|
|
|
MyToast().tip(
|
|
|
|
title: '点赞失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 关注用户
|
|
|
|
Future<void> followUser() async {
|
|
|
|
try {
|
|
|
|
final vlogerId = videoData['vlogerId'] ?? videoData['memberId'];
|
|
|
|
final res = await ImService.instance.followUser(userIDList: [vlogerId]);
|
|
|
|
if (res.success) {
|
|
|
|
setState(() {
|
|
|
|
videoData['doIFollowVloger'] = true;
|
|
|
|
});
|
|
|
|
MyToast().tip(
|
|
|
|
title: '关注成功',
|
|
|
|
position: 'center',
|
|
|
|
type: 'success',
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
MyToast().tip(
|
|
|
|
title: '关注失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
logger.e('关注用户异常: $e');
|
|
|
|
MyToast().tip(
|
|
|
|
title: '关注失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 取消关注用户
|
|
|
|
Future<void> unfollowUser() async {
|
|
|
|
try {
|
|
|
|
final vlogerId = videoData['vlogerId'] ?? videoData['memberId'];
|
|
|
|
} catch (e) {
|
|
|
|
logger.e('取消关注用户异常: $e');
|
|
|
|
MyToast().tip(
|
|
|
|
title: '操作失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 评论弹框
|
|
|
|
void handleComment() {
|
|
|
|
final videoId = videoData['id'];
|
|
|
|
logger.i('点击了评论按钮$videoId');
|
|
|
|
|
|
|
|
showModalBottomSheet(
|
|
|
|
backgroundColor: Colors.white,
|
|
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))),
|
|
|
|
showDragHandle: false,
|
|
|
|
clipBehavior: Clip.antiAlias,
|
|
|
|
isScrollControlled: true,
|
|
|
|
constraints: BoxConstraints(
|
|
|
|
maxHeight: MediaQuery.of(context).size.height * 3 / 4,
|
|
|
|
),
|
|
|
|
context: context,
|
|
|
|
builder: (context) {
|
|
|
|
return CommentBottomSheet(
|
|
|
|
videoId: videoId,
|
|
|
|
onCommentCountChanged: (newCount) {
|
|
|
|
// 更新视频的评论数量
|
|
|
|
setState(() {
|
|
|
|
videoData['commentsCounts'] = newCount;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// 分享弹框
|
|
|
|
void handleShare() {
|
|
|
|
if (chatController.chatList.isNotEmpty) {
|
|
|
|
chatController.getConversationList();
|
|
|
|
}
|
|
|
|
showModalBottomSheet(
|
|
|
|
backgroundColor: Colors.white,
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(15.0)),
|
|
|
|
),
|
|
|
|
clipBehavior: Clip.antiAlias,
|
|
|
|
context: context,
|
|
|
|
isScrollControlled: true,
|
|
|
|
builder: (context) {
|
2025-09-04 22:19:56 +08:00
|
|
|
final filteredList = chatController.chatList.where((item) => item.conversation.conversationGroupList?.isEmpty == true).toList();
|
|
|
|
|
2025-08-30 16:49:21 +08:00
|
|
|
return Material(
|
|
|
|
color: Colors.white,
|
|
|
|
child: Padding(
|
|
|
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
|
|
|
child: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
// 分享列表
|
|
|
|
SizedBox(
|
|
|
|
height: 110,
|
|
|
|
child: ListView.builder(
|
|
|
|
scrollDirection: Axis.horizontal,
|
|
|
|
itemCount: shareList.length,
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0),
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
return GestureDetector(
|
|
|
|
onTap: () => handleShareClick(index),
|
|
|
|
child: Container(
|
|
|
|
width: 64,
|
|
|
|
margin: EdgeInsets.symmetric(horizontal: 8.0),
|
|
|
|
child: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
Image.asset('${shareList[index]['icon']}', width: 48.0),
|
|
|
|
SizedBox(height: 5),
|
|
|
|
Text(
|
|
|
|
'${shareList[index]['label']}',
|
|
|
|
style: TextStyle(fontSize: 12.0),
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
// 会话列表
|
2025-09-04 22:19:56 +08:00
|
|
|
if (filteredList.isNotEmpty)
|
2025-08-30 16:49:21 +08:00
|
|
|
SizedBox(
|
|
|
|
height: 110,
|
|
|
|
child: ListView.builder(
|
|
|
|
scrollDirection: Axis.horizontal,
|
2025-09-04 22:19:56 +08:00
|
|
|
itemCount: filteredList.length,
|
2025-08-30 16:49:21 +08:00
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 20.0),
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
return GestureDetector(
|
|
|
|
onTap: () => handlCoverClick(filteredList[index].conversation),
|
|
|
|
child: Container(
|
|
|
|
width: 64,
|
|
|
|
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
|
|
child: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
2025-09-04 22:19:56 +08:00
|
|
|
ClipOval(
|
|
|
|
child: NetworkOrAssetImage(
|
|
|
|
imageUrl: filteredList[index].faceUrl,
|
|
|
|
width: 48.0,
|
|
|
|
height: 48.0,
|
|
|
|
),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
const SizedBox(height: 5),
|
|
|
|
Text(
|
|
|
|
filteredList[index].conversation.showName ?? '',
|
|
|
|
style: const TextStyle(fontSize: 12.0),
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
// 取消按钮
|
|
|
|
SafeArea(
|
|
|
|
top: false,
|
|
|
|
child: InkWell(
|
|
|
|
onTap: () => Get.back(),
|
|
|
|
child: Container(
|
|
|
|
alignment: Alignment.center,
|
|
|
|
width: double.infinity,
|
|
|
|
height: 50.0,
|
|
|
|
color: Colors.grey[50],
|
|
|
|
child: Text(
|
|
|
|
'取消',
|
|
|
|
style: TextStyle(color: Colors.black87),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
void handleShareClick(int index) {
|
|
|
|
final videoUrl = videoData['url'];
|
|
|
|
final description = videoData['title'] ?? '快来看看这个视频';
|
|
|
|
|
|
|
|
if (index == 0) {
|
|
|
|
// 分享好友
|
2025-09-06 14:38:16 +08:00
|
|
|
Wxsdk.shareToFriend(title: '快来看看这个视频', description: description, webpageUrl: '${ShareType.video.name}?id=${videoData['id']}');
|
2025-08-30 16:49:21 +08:00
|
|
|
} else if (index == 1) {
|
|
|
|
// 分享到朋友圈
|
2025-09-06 14:38:16 +08:00
|
|
|
Wxsdk.shareToTimeline(title: '快来看看这个视频', webpageUrl: '${ShareType.video.name}?id=${videoData['id']}');
|
2025-08-30 16:49:21 +08:00
|
|
|
} else if (index == 2) {
|
|
|
|
// 复制链接到剪切板
|
2025-09-06 14:38:16 +08:00
|
|
|
copyToClipboard(videoUrl);
|
2025-08-30 16:49:21 +08:00
|
|
|
} else if (index == 3) {
|
|
|
|
// 下载视频到本地
|
|
|
|
_downloadVideoWithDio(videoUrl, description);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 复制链接到剪贴板
|
|
|
|
void copyToClipboard(String text) async {
|
|
|
|
try {
|
|
|
|
await Clipboard.setData(ClipboardData(text: text));
|
|
|
|
MyToast().tip(
|
|
|
|
title: '链接已复制到剪贴板',
|
|
|
|
position: 'center',
|
|
|
|
type: 'success',
|
|
|
|
);
|
|
|
|
} catch (e) {
|
|
|
|
MyToast().tip(
|
|
|
|
title: '复制失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 下载视频
|
|
|
|
Future<void> _downloadVideoWithDio(String videoUrl, String fileName) async {
|
|
|
|
try {
|
|
|
|
// 请求存储权限
|
|
|
|
var status = await Permissions.requestStoragePermission();
|
|
|
|
if (!status) {
|
|
|
|
MyToast().tip(
|
|
|
|
title: '需要存储权限才能下载视频',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
await DownloadManager.downloadFile(
|
|
|
|
url: videoUrl,
|
|
|
|
fileName: '$fileName.mp4',
|
|
|
|
onProgress: (progress) {
|
|
|
|
print("下载进度: $progress%");
|
|
|
|
},
|
|
|
|
onComplete: (filePath) {
|
|
|
|
MyToast().tip(
|
|
|
|
title: '下载完成',
|
|
|
|
position: 'center',
|
|
|
|
type: 'success',
|
|
|
|
);
|
|
|
|
},
|
|
|
|
onError: (error) {
|
|
|
|
MyToast().tip(
|
|
|
|
title: '下载失败: $error',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
} catch (e) {
|
|
|
|
print("下载视频失败: $e");
|
|
|
|
MyToast().tip(
|
|
|
|
title: '下载失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void handlCoverClick(V2TimConversation conv) async {
|
|
|
|
// 发送VideoMsg,获取当前视频信息
|
|
|
|
final userId = conv.userID;
|
2025-09-09 10:57:52 +08:00
|
|
|
logger.w(videoData);
|
2025-08-30 16:49:21 +08:00
|
|
|
final String url = videoData['url'];
|
2025-09-09 10:57:52 +08:00
|
|
|
final img = (videoData['cover'] != null && videoData['cover'].toString().isNotEmpty) ? videoData['cover'] : videoData['firstFrameImg'];
|
2025-08-30 16:49:21 +08:00
|
|
|
final width = videoData['width'];
|
|
|
|
final height = videoData['height'];
|
2025-09-09 10:57:52 +08:00
|
|
|
final videoId = videoData['id'];
|
2025-08-30 16:49:21 +08:00
|
|
|
final makeJson = jsonEncode({
|
|
|
|
"width": width,
|
|
|
|
"height": height,
|
|
|
|
"imgUrl": img,
|
|
|
|
"videoUrl": url,
|
2025-09-09 10:57:52 +08:00
|
|
|
"videoId": videoId,
|
|
|
|
"userID": Get.find<ImUserInfoController>().userID.value,
|
2025-08-30 16:49:21 +08:00
|
|
|
});
|
|
|
|
final res = await IMMessage().createCustomMessage(
|
|
|
|
data: makeJson,
|
|
|
|
);
|
|
|
|
if (res.success) {
|
|
|
|
final sendRes = await IMMessage().sendMessage(msg: res.data!.messageInfo!, toUserID: userId, cloudCustomData: SummaryType.shareVideo);
|
|
|
|
if (sendRes.success) {
|
|
|
|
MyToast().tip(
|
|
|
|
title: '分享成功',
|
|
|
|
position: 'center',
|
|
|
|
type: 'success',
|
|
|
|
);
|
|
|
|
Get.back();
|
|
|
|
} else {
|
|
|
|
logger.e(res.desc);
|
|
|
|
MyToast().tip(
|
|
|
|
title: '分享失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
logger.e(res.desc);
|
|
|
|
MyToast().tip(
|
|
|
|
title: '分享失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
if (isLoading) {
|
|
|
|
return Scaffold(
|
|
|
|
backgroundColor: Colors.black,
|
|
|
|
appBar: AppBar(
|
|
|
|
backgroundColor: Colors.transparent,
|
|
|
|
elevation: 0,
|
|
|
|
leading: IconButton(
|
|
|
|
icon: Icon(Icons.arrow_back, color: Colors.white),
|
|
|
|
onPressed: () => Navigator.pop(context),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
body: Center(
|
|
|
|
child: CircularProgressIndicator(color: Colors.white),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
final videoWidth = videoData['width'] ?? 1;
|
|
|
|
final videoHeight = videoData['height'] ?? 1;
|
|
|
|
final isHorizontal = videoWidth > videoHeight;
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
backgroundColor: Colors.black,
|
|
|
|
appBar: AppBar(
|
|
|
|
backgroundColor: Colors.transparent,
|
|
|
|
elevation: 0,
|
|
|
|
leading: IconButton(
|
|
|
|
icon: Icon(Icons.arrow_back, color: Colors.white),
|
|
|
|
onPressed: () => Navigator.pop(context),
|
|
|
|
),
|
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
body: SafeArea(
|
|
|
|
bottom: true,
|
|
|
|
child: Stack(
|
|
|
|
children: [
|
|
|
|
// 视频区域
|
|
|
|
Positioned.fill(
|
|
|
|
child: GestureDetector(
|
|
|
|
child: Stack(
|
|
|
|
children: [
|
|
|
|
Visibility(
|
|
|
|
visible: position > Duration.zero,
|
|
|
|
child: Video(
|
|
|
|
controller: videoController,
|
|
|
|
fit: isHorizontal ? BoxFit.contain : BoxFit.cover,
|
|
|
|
controls: NoVideoControls,
|
|
|
|
),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
AnimatedOpacity(
|
|
|
|
opacity: position > Duration(milliseconds: 100) ? 0.0 : 1.0,
|
|
|
|
duration: Duration(milliseconds: 50),
|
|
|
|
child: Image.network(
|
|
|
|
videoData['firstFrameImg'] ?? 'https://wuzhongjie.com.cn/download/logo.png',
|
|
|
|
fit: isHorizontal ? BoxFit.contain : BoxFit.cover,
|
|
|
|
width: double.infinity,
|
|
|
|
height: double.infinity,
|
|
|
|
),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
StreamBuilder(
|
|
|
|
stream: player.stream.playing,
|
|
|
|
builder: (context, playing) {
|
|
|
|
return Visibility(
|
|
|
|
visible: playing.data == false,
|
|
|
|
child: Center(
|
|
|
|
child: IconButton(
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
onPressed: () {
|
|
|
|
player.playOrPause();
|
|
|
|
},
|
|
|
|
icon: Icon(
|
|
|
|
playing.data == true ? Icons.pause : Icons.play_arrow_rounded,
|
|
|
|
color: Colors.white60,
|
|
|
|
size: 80,
|
|
|
|
),
|
|
|
|
style: ButtonStyle(backgroundColor: WidgetStateProperty.all(Colors.black.withAlpha(15))),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
onTap: () {
|
|
|
|
player.playOrPause();
|
|
|
|
},
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
),
|
|
|
|
|
2025-09-09 10:57:52 +08:00
|
|
|
// 右侧操作栏
|
|
|
|
Positioned(
|
|
|
|
bottom: 100.0,
|
|
|
|
right: 6.0,
|
|
|
|
child: Column(
|
|
|
|
spacing: 15.0,
|
|
|
|
children: [
|
|
|
|
Stack(
|
|
|
|
children: [
|
|
|
|
SizedBox(
|
|
|
|
height: 55.0,
|
|
|
|
width: 48.0,
|
|
|
|
child: GestureDetector(
|
|
|
|
onTap: () async {
|
|
|
|
player.pause();
|
|
|
|
// 跳转到 Vloger 页面并等待返回结果
|
|
|
|
final result = await Get.toNamed('/vloger', arguments: videoData);
|
|
|
|
if (result != null) {
|
|
|
|
// 处理返回的参数
|
|
|
|
player.play();
|
|
|
|
setState(() {
|
|
|
|
videoData['doIFollowVloger'] = result['followStatus'];
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
child: UnconstrainedBox(
|
|
|
|
alignment: Alignment.topCenter,
|
|
|
|
child: Container(
|
|
|
|
height: 48.0,
|
|
|
|
width: 48.0,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
border: Border.all(color: Colors.white, width: 2.0),
|
|
|
|
borderRadius: BorderRadius.circular(100.0),
|
|
|
|
),
|
|
|
|
child: ClipOval(
|
|
|
|
child: NetworkOrAssetImage(
|
|
|
|
imageUrl: videoData['vlogerFace'] ?? videoData['commentUserFace'],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
bottom: 0,
|
|
|
|
left: 15.0,
|
|
|
|
child: InkWell(
|
2025-08-30 16:49:21 +08:00
|
|
|
child: Container(
|
2025-09-09 10:57:52 +08:00
|
|
|
height: 18.0,
|
|
|
|
width: 18.0,
|
2025-08-30 16:49:21 +08:00
|
|
|
decoration: BoxDecoration(
|
2025-09-09 10:57:52 +08:00
|
|
|
color: videoData['doIFollowVloger'] == true ? Colors.white : Color(0xFFFF5000),
|
2025-08-30 16:49:21 +08:00
|
|
|
borderRadius: BorderRadius.circular(100.0),
|
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
child: Icon(
|
|
|
|
videoData['doIFollowVloger'] == true ? Icons.check : Icons.add,
|
|
|
|
color: videoData['doIFollowVloger'] == true ? Color(0xFFFF5000) : Colors.white,
|
|
|
|
size: 14.0,
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
onTap: () async {
|
|
|
|
if (videoData['doIFollowVloger'] == true) {
|
|
|
|
await unfollowUser();
|
|
|
|
} else {
|
|
|
|
await followUser();
|
|
|
|
}
|
|
|
|
},
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
],
|
|
|
|
),
|
|
|
|
GestureDetector(
|
|
|
|
child: Column(
|
|
|
|
children: [
|
|
|
|
SvgPicture.asset(
|
|
|
|
'assets/images/svg/heart.svg',
|
|
|
|
colorFilter: ColorFilter.mode(videoData['doILikeThisVlog'] == true ? Color(0xFFFF5000) : Colors.white, BlendMode.srcIn),
|
|
|
|
height: 40.0,
|
|
|
|
width: 40.0,
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
Text(
|
|
|
|
'${videoData['likeCounts'] ?? 0}',
|
|
|
|
style: TextStyle(color: Colors.white, fontSize: 12.0),
|
|
|
|
),
|
|
|
|
],
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
onTap: () {
|
|
|
|
if (videoData['doILikeThisVlog'] == true) {
|
|
|
|
doUnLikeVideo();
|
|
|
|
} else {
|
|
|
|
doLikeVideo();
|
|
|
|
}
|
|
|
|
},
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
GestureDetector(
|
|
|
|
child: Column(
|
|
|
|
children: [
|
|
|
|
SvgPicture.asset(
|
|
|
|
'assets/images/svg/reply.svg',
|
|
|
|
colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
|
|
|
height: 40.0,
|
|
|
|
width: 40.0,
|
|
|
|
),
|
|
|
|
Text(
|
|
|
|
'${videoData['commentsCounts'] ?? 0}',
|
|
|
|
style: TextStyle(color: Colors.white, fontSize: 12.0),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
onTap: () {
|
|
|
|
handleComment();
|
|
|
|
},
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
GestureDetector(
|
|
|
|
child: Column(
|
|
|
|
children: [
|
|
|
|
SvgPicture.asset(
|
|
|
|
'assets/images/svg/share.svg',
|
|
|
|
colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
|
|
|
height: 40.0,
|
|
|
|
width: 40.0,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
onTap: () {
|
|
|
|
handleShare();
|
|
|
|
},
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
GestureDetector(
|
|
|
|
child: Column(
|
|
|
|
children: [
|
|
|
|
SvgPicture.asset(
|
|
|
|
'assets/images/svg/report.svg',
|
|
|
|
colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
|
|
|
height: 40.0,
|
|
|
|
width: 40.0,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
onTap: () async {
|
|
|
|
player.pause();
|
|
|
|
// 跳转到举报页面并等待返回结果
|
|
|
|
final result = await Get.toNamed('/report', arguments: videoData);
|
|
|
|
if (result != null) {
|
|
|
|
player.play();
|
|
|
|
}
|
|
|
|
},
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
],
|
|
|
|
),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
|
2025-09-09 10:57:52 +08:00
|
|
|
// 底部信息区域
|
|
|
|
Positioned(
|
|
|
|
bottom: 15.0,
|
|
|
|
left: 10.0,
|
|
|
|
right: 80.0,
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
'@${videoData['vlogerNickname'] ?? videoData['commentUserNickname'] ?? '未知用户'}',
|
|
|
|
style: const TextStyle(color: Colors.white, fontSize: 16.0),
|
|
|
|
),
|
|
|
|
LayoutBuilder(
|
|
|
|
builder: (context, constraints) {
|
|
|
|
final text = videoData['title'] ?? '未知';
|
|
|
|
final span = TextSpan(
|
|
|
|
text: text,
|
|
|
|
style: const TextStyle(color: Colors.white, fontSize: 14.0),
|
|
|
|
);
|
|
|
|
final tp = TextPainter(
|
|
|
|
text: span,
|
|
|
|
maxLines: 3,
|
|
|
|
textDirection: TextDirection.ltr,
|
|
|
|
);
|
|
|
|
tp.layout(maxWidth: constraints.maxWidth);
|
|
|
|
final isOverflow = tp.didExceedMaxLines;
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
text,
|
|
|
|
maxLines: videoData['expanded'] ?? false ? null : 3,
|
|
|
|
overflow: videoData['expanded'] ?? false ? TextOverflow.visible : TextOverflow.ellipsis,
|
|
|
|
style: const TextStyle(color: Colors.white, fontSize: 14.0),
|
|
|
|
),
|
|
|
|
if (isOverflow)
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.only(top: 6.0),
|
|
|
|
child: GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
setState(() {
|
|
|
|
videoData['expanded'] = !(videoData['expanded'] ?? false);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
child: Text(
|
|
|
|
videoData['expanded'] ?? false ? '收起' : '展开更多',
|
|
|
|
textAlign: TextAlign.right,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontSize: 14,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
|
2025-09-09 10:57:52 +08:00
|
|
|
// 进度条
|
|
|
|
Positioned(
|
|
|
|
bottom: 0.0,
|
|
|
|
left: 6.0,
|
|
|
|
right: 6.0,
|
|
|
|
child: Visibility(
|
|
|
|
visible: position > Duration.zero,
|
|
|
|
child: Listener(
|
|
|
|
child: SliderTheme(
|
|
|
|
data: SliderThemeData(
|
|
|
|
trackHeight: sliderDraging ? 6.0 : 2.0,
|
|
|
|
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 4.0),
|
|
|
|
overlayShape: RoundSliderOverlayShape(overlayRadius: 0),
|
|
|
|
inactiveTrackColor: Colors.white24,
|
|
|
|
activeTrackColor: Colors.white,
|
|
|
|
thumbColor: Colors.white,
|
|
|
|
overlayColor: Colors.transparent,
|
|
|
|
),
|
|
|
|
child: Slider(
|
|
|
|
value: sliderValue,
|
|
|
|
onChanged: (value) async {
|
|
|
|
setState(() {
|
|
|
|
sliderValue = value;
|
|
|
|
});
|
|
|
|
await player.seek(duration * value.clamp(0.0, 1.0));
|
|
|
|
},
|
|
|
|
onChangeEnd: (value) async {
|
|
|
|
setState(() {
|
|
|
|
sliderDraging = false;
|
|
|
|
});
|
|
|
|
if (!player.state.playing) {
|
|
|
|
await player.play();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
2025-09-09 10:57:52 +08:00
|
|
|
onPointerMove: (e) {
|
|
|
|
setState(() {
|
|
|
|
sliderDraging = true;
|
|
|
|
});
|
|
|
|
},
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
2025-09-09 10:57:52 +08:00
|
|
|
// 时间显示
|
|
|
|
Positioned(
|
|
|
|
bottom: 100.0,
|
|
|
|
left: 10.0,
|
|
|
|
right: 10.0,
|
|
|
|
child: Visibility(
|
|
|
|
visible: sliderDraging,
|
|
|
|
child: DefaultTextStyle(
|
|
|
|
style: TextStyle(color: Colors.white54, fontSize: 18.0, fontFamily: 'Arial'),
|
|
|
|
child: Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
spacing: 8.0,
|
|
|
|
children: [
|
|
|
|
Text(position.label(reference: duration), style: TextStyle(color: Colors.white)),
|
|
|
|
Text('/', style: TextStyle(fontSize: 14.0)),
|
|
|
|
Text(duration.label(reference: duration)),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
)),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
2025-08-30 16:49:21 +08:00
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
2025-09-04 22:19:56 +08:00
|
|
|
|
2025-08-30 16:49:21 +08:00
|
|
|
class CommentBottomSheet extends StatefulWidget {
|
|
|
|
final String videoId;
|
|
|
|
final Function(int) onCommentCountChanged; // 新增回调函数
|
|
|
|
|
|
|
|
const CommentBottomSheet({
|
|
|
|
super.key,
|
|
|
|
required this.videoId,
|
|
|
|
required this.onCommentCountChanged, // 接收回调
|
|
|
|
});
|
|
|
|
|
|
|
|
@override
|
|
|
|
_CommentBottomSheetState createState() => _CommentBottomSheetState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _CommentBottomSheetState extends State<CommentBottomSheet> {
|
|
|
|
// 评论相关状态(移动到内部)
|
|
|
|
int commentPage = 1;
|
|
|
|
final int commentPageSize = 10;
|
|
|
|
bool isLoadingMoreComments = false;
|
|
|
|
bool hasMoreComments = true;
|
|
|
|
List<Map<String, dynamic>> commentList = [];
|
|
|
|
String replyingCommentId = '';
|
|
|
|
String replyingCommentUser = '';
|
|
|
|
|
|
|
|
// 新增:子评论相关状态
|
|
|
|
Map<String, dynamic> expandedReplies = {}; // 存储已展开的回复 {commentId: true/false}
|
|
|
|
Map<String, List<Map<String, dynamic>>> replyData = {}; // 存储子评论数据 {commentId: [replies]}
|
|
|
|
Map<String, int> replyPage = {}; // 子评论分页
|
|
|
|
Map<String, bool> isLoadingReplies = {}; // 子评论加载状态
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
// 初始化时加载评论
|
|
|
|
fetchComments(false);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> fetchComments(bool loadMore) async {
|
|
|
|
if (isLoadingMoreComments && !loadMore) return;
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
isLoadingMoreComments = true;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!loadMore) {
|
|
|
|
commentPage = 1;
|
|
|
|
hasMoreComments = true;
|
|
|
|
commentList.clear();
|
|
|
|
}
|
|
|
|
logger.d('入参vlogId-------------------->: ${widget.videoId}');
|
|
|
|
try {
|
|
|
|
final res = await Http.post(VideoApi.videoCommentList, data: {
|
|
|
|
'vlogId': widget.videoId,
|
|
|
|
'current': commentPage,
|
|
|
|
'size': commentPageSize,
|
|
|
|
});
|
|
|
|
|
|
|
|
logger.d('评论接口列表返回: ${json.encode(res)}');
|
|
|
|
|
|
|
|
if (res['code'] == 200 && res['data'] != null) {
|
|
|
|
final data = res['data'];
|
|
|
|
final List<Map<String, dynamic>> newComments = List<Map<String, dynamic>>.from(data['records'] ?? []);
|
|
|
|
final int total = data['total'] ?? 0;
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
if (loadMore) {
|
|
|
|
commentList.addAll(newComments);
|
|
|
|
} else {
|
|
|
|
commentList = newComments;
|
|
|
|
}
|
|
|
|
hasMoreComments = commentList.length < total;
|
|
|
|
commentPage++;
|
|
|
|
});
|
|
|
|
widget.onCommentCountChanged(total);
|
|
|
|
logger.d('成功加载 ${newComments.length} 条评论,总共 $total 条');
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
logger.e('获取评论异常: $e');
|
|
|
|
} finally {
|
|
|
|
setState(() {
|
|
|
|
isLoadingMoreComments = false;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> postComment(String content, {String parentCommentId = ''}) async {
|
|
|
|
try {
|
|
|
|
final res = await Http.post(VideoApi.doVideoComment, data: {
|
|
|
|
'vlogId': widget.videoId,
|
|
|
|
'content': content,
|
|
|
|
'fatherCommentId': parentCommentId.isNotEmpty ? parentCommentId : null,
|
|
|
|
});
|
|
|
|
|
|
|
|
if (res['code'] == 200) {
|
|
|
|
// 如果是回复,刷新对应的子评论
|
|
|
|
if (parentCommentId.isNotEmpty) {
|
|
|
|
fetchReplies(parentCommentId, false);
|
|
|
|
// 更新主评论的子评论数量
|
|
|
|
setState(() {
|
|
|
|
final comment = commentList.firstWhere((c) => c['id'] == parentCommentId, orElse: () => <String, dynamic>{});
|
|
|
|
if (comment.isNotEmpty) {
|
|
|
|
comment['childCount'] = (comment['childCount'] ?? 0) + 1;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
// 如果是新评论,刷新整个列表
|
|
|
|
fetchComments(false);
|
|
|
|
}
|
|
|
|
widget.onCommentCountChanged(commentList.length + 1);
|
|
|
|
MyToast().tip(
|
|
|
|
title: '评论成功',
|
|
|
|
position: 'center',
|
|
|
|
type: 'success',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
logger.i('发布评论失败: $e');
|
|
|
|
MyToast().tip(
|
|
|
|
title: '评论失败',
|
|
|
|
position: 'center',
|
|
|
|
type: 'error',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 获取二级评论,复用一级评论的接口,修改传参
|
|
|
|
Future<void> fetchReplies(String commentId, bool loadMore) async {
|
|
|
|
if (isLoadingReplies[commentId] == true && !loadMore) return;
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
isLoadingReplies[commentId] = true;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!loadMore) {
|
|
|
|
replyPage[commentId] = 1;
|
|
|
|
replyData[commentId] = [];
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
final res = await Http.post(VideoApi.videoCommentList, data: {
|
|
|
|
'fatherCommentId': commentId,
|
|
|
|
'current': replyPage[commentId],
|
|
|
|
'size': commentPageSize,
|
|
|
|
});
|
|
|
|
|
|
|
|
if (res['code'] == 200 && res['data'] != null) {
|
|
|
|
final data = res['data'];
|
|
|
|
final List<Map<String, dynamic>> newReplies = List<Map<String, dynamic>>.from(data['records'] ?? []);
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
if (loadMore) {
|
|
|
|
replyData[commentId]!.addAll(newReplies);
|
|
|
|
} else {
|
|
|
|
replyData[commentId] = newReplies;
|
|
|
|
}
|
|
|
|
replyPage[commentId] = replyPage[commentId]! + 1;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
logger.e('获取子评论异常: $e');
|
|
|
|
} finally {
|
|
|
|
setState(() {
|
|
|
|
isLoadingReplies[commentId] = false;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Material(
|
|
|
|
color: Colors.white,
|
|
|
|
child: Column(
|
|
|
|
children: [
|
|
|
|
Container(
|
|
|
|
padding: EdgeInsets.fromLTRB(15.0, 10.0, 10.0, 5.0),
|
|
|
|
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFFAFAFA)))),
|
|
|
|
child: Column(
|
|
|
|
spacing: 10.0,
|
|
|
|
children: [
|
|
|
|
Row(
|
|
|
|
children: [
|
|
|
|
/* Expanded(
|
|
|
|
child: Text.rich(TextSpan(children: [
|
|
|
|
TextSpan(
|
|
|
|
text: '大家都在搜: ',
|
|
|
|
style: TextStyle(color: Colors.grey),
|
|
|
|
),
|
|
|
|
TextSpan(
|
|
|
|
text: '黑神话-悟空',
|
|
|
|
style: TextStyle(color: const Color(0xFF496D80)),
|
|
|
|
),
|
|
|
|
]))),*/
|
|
|
|
GestureDetector(
|
|
|
|
child: Container(
|
|
|
|
height: 22.0,
|
|
|
|
width: 22.0,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.grey[200],
|
|
|
|
borderRadius: BorderRadius.circular(100.0),
|
|
|
|
),
|
|
|
|
child: UnconstrainedBox(child: Icon(Icons.close, color: Colors.black45, size: 14.0))),
|
|
|
|
onTap: () {
|
|
|
|
Navigator.pop(context);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
Text(
|
|
|
|
'${commentList.length}条评论',
|
|
|
|
style: TextStyle(fontSize: 12.0, fontWeight: FontWeight.w600),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Expanded(
|
|
|
|
child: NotificationListener<ScrollNotification>(
|
|
|
|
onNotification: (ScrollNotification scrollInfo) {
|
|
|
|
if (scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent && !isLoadingMoreComments && hasMoreComments) {
|
|
|
|
fetchComments(true);
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
},
|
|
|
|
child: commentList.isEmpty && !isLoadingMoreComments
|
|
|
|
? Center(
|
|
|
|
child: Text(
|
|
|
|
'暂无评论',
|
|
|
|
style: TextStyle(color: Colors.grey),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: ListView.builder(
|
|
|
|
physics: BouncingScrollPhysics(),
|
|
|
|
itemCount: commentList.length + (hasMoreComments ? 1 : 0),
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
if (index == commentList.length) {
|
|
|
|
return Center(
|
|
|
|
child: Padding(
|
|
|
|
padding: EdgeInsets.all(16.0),
|
|
|
|
child: isLoadingMoreComments ? CircularProgressIndicator() : Text('没有更多评论了'),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
final comment = commentList[index];
|
|
|
|
final hasReplies = comment['childCount'] > 0;
|
|
|
|
final isExpanded = expandedReplies[comment['id']] == true;
|
|
|
|
final replies = replyData[comment['id']] ?? [];
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
children: [
|
|
|
|
// 主评论
|
|
|
|
ListTile(
|
|
|
|
isThreeLine: true,
|
|
|
|
leading: ClipRRect(
|
|
|
|
borderRadius: BorderRadius.circular(50.0),
|
|
|
|
child: NetworkOrAssetImage(
|
|
|
|
imageUrl: comment['commentUserFace'] ?? 'assets/images/avatar/default.png',
|
|
|
|
width: 30.0,
|
|
|
|
height: 30.0,
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
title: Row(
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
child: Text(
|
|
|
|
comment['commentUserNickname'] ?? '未知用户',
|
|
|
|
style: TextStyle(
|
|
|
|
color: Colors.grey,
|
|
|
|
fontSize: 12.0,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
SizedBox(width: 20.0),
|
|
|
|
GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
setState(() {
|
|
|
|
replyingCommentId = comment['id'];
|
|
|
|
replyingCommentUser = comment['commentUserNickname'] ?? '未知用户';
|
|
|
|
});
|
|
|
|
},
|
|
|
|
child: Icon(
|
|
|
|
Icons.reply,
|
|
|
|
color: Colors.black54,
|
|
|
|
size: 16.0,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
subtitle: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Container(
|
|
|
|
margin: EdgeInsets.symmetric(vertical: 5.0),
|
|
|
|
child: Text(
|
|
|
|
comment['content'] ?? '',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 14.0,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
if (hasReplies)
|
|
|
|
GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
setState(() {
|
|
|
|
expandedReplies[comment['id']] = !isExpanded;
|
|
|
|
if (expandedReplies[comment['id']] == true &&
|
|
|
|
(replyData[comment['id']] == null || replyData[comment['id']]!.isEmpty)) {
|
|
|
|
fetchReplies(comment['id'], false);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
},
|
|
|
|
child: Container(
|
|
|
|
margin: EdgeInsets.only(right: 15.0),
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 3.0),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.grey[100],
|
|
|
|
borderRadius: BorderRadius.circular(20.0),
|
|
|
|
),
|
|
|
|
child: Row(children: [
|
|
|
|
Text(
|
|
|
|
'${comment['childCount']}回复',
|
|
|
|
style: TextStyle(fontSize: 12.0),
|
|
|
|
),
|
|
|
|
Icon(
|
|
|
|
isExpanded ? Icons.arrow_drop_up : Icons.arrow_drop_down,
|
|
|
|
size: 16.0,
|
|
|
|
)
|
|
|
|
]),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Text(
|
|
|
|
comment['createTime']?.toString().substring(0, 10) ?? '',
|
|
|
|
style: TextStyle(color: Colors.grey, fontSize: 12.0),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
// 子评论列表
|
|
|
|
if (isExpanded && replies.isNotEmpty)
|
|
|
|
Padding(
|
|
|
|
padding: EdgeInsets.only(left: 40.0),
|
|
|
|
child: Column(
|
|
|
|
children: [
|
|
|
|
...replies.map((reply) => ListTile(
|
|
|
|
leading: ClipRRect(
|
|
|
|
borderRadius: BorderRadius.circular(25.0),
|
|
|
|
child: NetworkOrAssetImage(
|
|
|
|
imageUrl: reply['commentUserFace'] ?? 'assets/images/avatar/default.png',
|
|
|
|
width: 25.0,
|
|
|
|
height: 25.0,
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
title: Row(
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
child: Text(
|
|
|
|
reply['commentUserNickname'] ?? '未知用户',
|
|
|
|
style: TextStyle(
|
|
|
|
color: Colors.grey,
|
|
|
|
fontSize: 11.0,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
setState(() {
|
|
|
|
replyingCommentId = comment['id'];
|
|
|
|
replyingCommentUser = reply['commentUserNickname'] ?? '未知用户';
|
|
|
|
});
|
|
|
|
},
|
|
|
|
child: Icon(
|
|
|
|
Icons.reply,
|
|
|
|
color: Colors.black54,
|
|
|
|
size: 14.0,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
subtitle: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Container(
|
|
|
|
margin: EdgeInsets.symmetric(vertical: 3.0),
|
|
|
|
child: Text(
|
|
|
|
reply['content'] ?? '',
|
|
|
|
style: TextStyle(fontSize: 13.0),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Text(
|
|
|
|
reply['createTime']?.toString().substring(0, 10) ?? '',
|
|
|
|
style: TextStyle(color: Colors.grey, fontSize: 11.0),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
)),
|
|
|
|
|
|
|
|
// 加载更多子评论按钮
|
|
|
|
if (replies.length < comment['childCount'])
|
|
|
|
Center(
|
|
|
|
child: TextButton(
|
|
|
|
onPressed: () => fetchReplies(comment['id'], true),
|
|
|
|
child: isLoadingReplies[comment['id']] == true ? CircularProgressIndicator() : Text('加载更多回复'),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
Divider(height: 1, color: Colors.grey[200]),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
// 在回复输入区域显示更详细的信息
|
|
|
|
if (replyingCommentId.isNotEmpty)
|
|
|
|
Container(
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
|
|
color: Colors.grey[100],
|
|
|
|
child: Row(
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
child: Text(
|
|
|
|
'回复 @$replyingCommentUser',
|
|
|
|
style: TextStyle(fontSize: 12.0, color: Colors.blue),
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
setState(() {
|
|
|
|
replyingCommentId = '';
|
|
|
|
replyingCommentUser = '';
|
|
|
|
});
|
|
|
|
},
|
|
|
|
child: Icon(Icons.close, size: 16.0),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
GestureDetector(
|
|
|
|
child: Container(
|
|
|
|
margin: EdgeInsets.all(10.0),
|
|
|
|
height: 40.0,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.grey[100],
|
|
|
|
borderRadius: BorderRadius.circular(30.0),
|
|
|
|
),
|
|
|
|
child: Row(
|
|
|
|
children: [
|
|
|
|
SizedBox(width: 15.0),
|
|
|
|
Icon(
|
|
|
|
Icons.edit_note,
|
|
|
|
color: Colors.black54,
|
|
|
|
size: 16.0,
|
|
|
|
),
|
|
|
|
SizedBox(width: 5.0),
|
|
|
|
Text(
|
|
|
|
replyingCommentId.isNotEmpty ? '回复 @$replyingCommentUser' : '说点什么...',
|
|
|
|
style: TextStyle(color: Colors.black54, fontSize: 14.0),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
onTap: () {
|
|
|
|
Navigator.push(
|
|
|
|
context,
|
|
|
|
FadeRoute(
|
|
|
|
child: PopupReply(
|
|
|
|
hintText: replyingCommentId.isNotEmpty ? '回复 @$replyingCommentUser' : '说点什么...',
|
|
|
|
onChanged: (value) {
|
|
|
|
debugPrint('评论内容: $value');
|
|
|
|
},
|
|
|
|
onSubmitted: (value) {
|
|
|
|
if (value.isNotEmpty) {
|
|
|
|
postComment(value, parentCommentId: replyingCommentId);
|
|
|
|
setState(() {
|
|
|
|
replyingCommentId = '';
|
|
|
|
replyingCommentUser = '';
|
|
|
|
});
|
|
|
|
Navigator.pop(context);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
)));
|
|
|
|
},
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
2025-09-04 22:19:56 +08:00
|
|
|
}
|