Merge branch 'main' into design

This commit is contained in:
guozhigq
2024-02-25 23:24:33 +08:00
33 changed files with 1961 additions and 212 deletions

View File

@ -104,17 +104,19 @@ class NetworkImgLayer extends StatelessWidget {
? 0 ? 0
: StyleString.imgRadius.x), : StyleString.imgRadius.x),
), ),
child: Center( child: type == 'bg'
child: Image.asset( ? const SizedBox()
type == 'avatar' : Center(
? 'assets/images/noface.jpeg' child: Image.asset(
: 'assets/images/loading.png', type == 'avatar'
width: width, ? 'assets/images/noface.jpeg'
height: height, : 'assets/images/loading.png',
cacheWidth: width.cacheSize(context), width: width,
cacheHeight: height.cacheSize(context), height: height,
), cacheWidth: width.cacheSize(context),
), cacheHeight: height.cacheSize(context),
),
),
); );
} }
} }

View File

@ -483,4 +483,20 @@ class Api {
/// 激活buvid3 /// 激活buvid3
static const activateBuvidApi = '/x/internal/gaia-gateway/ExClimbWuzhi'; static const activateBuvidApi = '/x/internal/gaia-gateway/ExClimbWuzhi';
/// 我的订阅
static const userSubFolder = '/x/v3/fav/folder/collected/list';
/// 我的订阅详情
static const userSubFolderDetail = '/x/space/fav/season/list';
/// 表情
static const emojiList = '/x/emote/user/panel/web';
/// 已读标记
static const String ackSessionMsg =
'${HttpString.tUrl}/session_svr/v1/session_svr/update_ack';
/// 发送私信
static const String sendMsg = '${HttpString.tUrl}/web_im/v1/web_im/send_msg';
} }

View File

@ -1,3 +1,4 @@
import 'dart:math';
import '../models/msg/account.dart'; import '../models/msg/account.dart';
import '../models/msg/session.dart'; import '../models/msg/session.dart';
import '../utils/wbi_sign.dart'; import '../utils/wbi_sign.dart';
@ -22,10 +23,18 @@ class MsgHttp {
Map signParams = await WbiSign().makSign(params); Map signParams = await WbiSign().makSign(params);
var res = await Request().get(Api.sessionList, data: signParams); var res = await Request().get(Api.sessionList, data: signParams);
if (res.data['code'] == 0) { if (res.data['code'] == 0) {
return { try {
'status': true, return {
'data': SessionDataModel.fromJson(res.data['data']), 'status': true,
}; 'data': SessionDataModel.fromJson(res.data['data']),
};
} catch (err) {
return {
'status': false,
'date': [],
'msg': err.toString(),
};
}
} else { } else {
return { return {
'status': false, 'status': false,
@ -42,12 +51,16 @@ class MsgHttp {
'mobi_app': 'web', 'mobi_app': 'web',
}); });
if (res.data['code'] == 0) { if (res.data['code'] == 0) {
return { try {
'status': true, return {
'data': res.data['data'] 'status': true,
.map<AccountListModel>((e) => AccountListModel.fromJson(e)) 'data': res.data['data']
.toList(), .map<AccountListModel>((e) => AccountListModel.fromJson(e))
}; .toList(),
};
} catch (err) {
print('err🔟: $err');
}
} else { } else {
return { return {
'status': false, 'status': false,
@ -86,4 +99,125 @@ class MsgHttp {
}; };
} }
} }
// 消息标记已读
static Future ackSessionMsg({
int? talkerId,
int? ackSeqno,
}) async {
String csrf = await Request.getCsrf();
Map params = await WbiSign().makSign({
'talker_id': talkerId,
'session_type': 1,
'ack_seqno': ackSeqno,
'build': 0,
'mobi_app': 'web',
'csrf_token': csrf,
'csrf': csrf
});
var res = await Request().get(Api.ackSessionMsg, data: params);
if (res.data['code'] == 0) {
return {
'status': true,
'data': res.data['data'],
};
} else {
return {
'status': false,
'date': [],
'msg': "message: ${res.data['message']},"
" msg: ${res.data['msg']},"
" code: ${res.data['code']}",
};
}
}
// 发送私信
static Future sendMsg({
int? senderUid,
int? receiverId,
int? receiverType,
int? msgType,
dynamic content,
}) async {
String csrf = await Request.getCsrf();
Map<String, dynamic> params = await WbiSign().makSign({
'msg[sender_uid]': senderUid,
'msg[receiver_id]': receiverId,
'msg[receiver_type]': receiverType ?? 1,
'msg[msg_type]': msgType ?? 1,
'msg[msg_status]': 0,
'msg[dev_id]': getDevId(),
'msg[timestamp]': DateTime.now().millisecondsSinceEpoch ~/ 1000,
'msg[new_face_version]': 0,
'msg[content]': content,
'from_firework': 0,
'build': 0,
'mobi_app': 'web',
'csrf_token': csrf,
'csrf': csrf,
});
var res =
await Request().post(Api.sendMsg, queryParameters: <String, dynamic>{
...params,
'csrf_token': csrf,
'csrf': csrf,
}, data: {
'w_sender_uid': params['msg[sender_uid]'],
'w_receiver_id': params['msg[receiver_id]'],
'w_dev_id': params['msg[dev_id]'],
'w_rid': params['w_rid'],
'wts': params['wts'],
'csrf_token': csrf,
'csrf': csrf,
});
if (res.data['code'] == 0) {
return {
'status': true,
'data': res.data['data'],
};
} else {
return {
'status': false,
'date': [],
'msg': "message: ${res.data['message']},"
" msg: ${res.data['msg']},"
" code: ${res.data['code']}",
};
}
}
static String getDevId() {
final List<String> b = [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F'
];
final List<String> s = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".split('');
for (int i = 0; i < s.length; i++) {
if ('-' == s[i] || '4' == s[i]) {
continue;
}
final int randomInt = Random().nextInt(16);
if ('x' == s[i]) {
s[i] = b[randomInt];
} else {
s[i] = b[3 & randomInt | 8];
}
}
return s.join();
}
} }

View File

@ -1,4 +1,5 @@
import '../models/video/reply/data.dart'; import '../models/video/reply/data.dart';
import '../models/video/reply/emote.dart';
import 'api.dart'; import 'api.dart';
import 'init.dart'; import 'init.dart';
@ -100,4 +101,23 @@ class ReplyHttp {
}; };
} }
} }
static Future getEmoteList({String? business}) async {
var res = await Request().get(Api.emojiList, data: {
'business': business ?? 'reply',
'web_location': '333.1245',
});
if (res.data['code'] == 0) {
return {
'status': true,
'data': EmoteModelData.fromJson(res.data['data']),
};
} else {
return {
'status': false,
'date': [],
'msg': res.data['message'],
};
}
}
} }

View File

@ -6,6 +6,8 @@ import '../models/user/fav_folder.dart';
import '../models/user/history.dart'; import '../models/user/history.dart';
import '../models/user/info.dart'; import '../models/user/info.dart';
import '../models/user/stat.dart'; import '../models/user/stat.dart';
import '../models/user/sub_detail.dart';
import '../models/user/sub_folder.dart';
import 'api.dart'; import 'api.dart';
import 'init.dart'; import 'init.dart';
@ -305,4 +307,46 @@ class UserHttp {
return {'status': false, 'msg': res.data['message']}; return {'status': false, 'msg': res.data['message']};
} }
} }
// 我的订阅
static Future userSubFolder({
required int mid,
required int pn,
required int ps,
}) async {
var res = await Request().get(Api.userSubFolder, data: {
'up_mid': mid,
'ps': ps,
'pn': pn,
'platform': 'web',
});
if (res.data['code'] == 0) {
return {
'status': true,
'data': SubFolderModelData.fromJson(res.data['data'])
};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
static Future userSubFolderDetail({
required int seasonId,
required int pn,
required int ps,
}) async {
var res = await Request().get(Api.userSubFolderDetail, data: {
'season_id': seasonId,
'ps': ps,
'pn': pn,
});
if (res.data['code'] == 0) {
return {
'status': true,
'data': SubDetailModelData.fromJson(res.data['data'])
};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
} }

View File

@ -8,7 +8,7 @@ class SessionDataModel {
this.hasMore, this.hasMore,
}); });
List? sessionList; List<SessionList>? sessionList;
int? hasMore; int? hasMore;
SessionDataModel.fromJson(Map<String, dynamic> json) { SessionDataModel.fromJson(Map<String, dynamic> json) {
@ -121,35 +121,37 @@ class LastMsg {
this.msgKey, this.msgKey,
this.msgStatus, this.msgStatus,
this.notifyCode, this.notifyCode,
this.newFaceVersion, // this.newFaceVersion,
}); });
int? senderIid; int? senderIid;
int? receiverType; int? receiverType;
int? receiverId; int? receiverId;
int? msgType; int? msgType;
Map? content; dynamic content;
int? msgSeqno; int? msgSeqno;
int? timestamp; int? timestamp;
String? atUids; String? atUids;
int? msgKey; int? msgKey;
int? msgStatus; int? msgStatus;
String? notifyCode; String? notifyCode;
int? newFaceVersion; // int? newFaceVersion;
LastMsg.fromJson(Map<String, dynamic> json) { LastMsg.fromJson(Map<String, dynamic> json) {
senderIid = json['sender_uid']; senderIid = json['sender_uid'];
receiverType = json['receiver_type']; receiverType = json['receiver_type'];
receiverId = json['receiver_id']; receiverId = json['receiver_id'];
msgType = json['msg_type']; msgType = json['msg_type'];
content = jsonDecode(json['content']); content = json['content'] != null && json['content'] != ''
? jsonDecode(json['content'])
: '';
msgSeqno = json['msg_seqno']; msgSeqno = json['msg_seqno'];
timestamp = json['timestamp']; timestamp = json['timestamp'];
atUids = json['at_uids']; atUids = json['at_uids'];
msgKey = json['msg_key']; msgKey = json['msg_key'];
msgStatus = json['msg_status']; msgStatus = json['msg_status'];
notifyCode = json['notify_code']; notifyCode = json['notify_code'];
newFaceVersion = json['new_face_version']; // newFaceVersion = json['new_face_version'];
} }
} }
@ -214,7 +216,9 @@ class MessageItem {
receiverId = json['receiver_id']; receiverId = json['receiver_id'];
// 1 文本 2 图片 18 系统提示 10 系统通知 5 撤回的消息 // 1 文本 2 图片 18 系统提示 10 系统通知 5 撤回的消息
msgType = json['msg_type']; msgType = json['msg_type'];
content = jsonDecode(json['content']); content = json['content'] != null && json['content'] != ''
? jsonDecode(json['content'])
: '';
msgSeqno = json['msg_seqno']; msgSeqno = json['msg_seqno'];
timestamp = json['timestamp']; timestamp = json['timestamp'];
atUids = json['at_uids']; atUids = json['at_uids'];

View File

@ -0,0 +1,123 @@
class SubDetailModelData {
DetailInfo? info;
List<SubDetailMediaItem>? medias;
SubDetailModelData({this.info, this.medias});
SubDetailModelData.fromJson(Map<String, dynamic> json) {
info = DetailInfo.fromJson(json['info']);
if (json['medias'] != null) {
medias = <SubDetailMediaItem>[];
json['medias'].forEach((v) {
medias!.add(SubDetailMediaItem.fromJson(v));
});
}
}
}
class SubDetailMediaItem {
int? id;
String? title;
String? cover;
String? pic;
int? duration;
int? pubtime;
String? bvid;
Map? upper;
Map? cntInfo;
int? enableVt;
String? vtDisplay;
SubDetailMediaItem({
this.id,
this.title,
this.cover,
this.pic,
this.duration,
this.pubtime,
this.bvid,
this.upper,
this.cntInfo,
this.enableVt,
this.vtDisplay,
});
SubDetailMediaItem.fromJson(Map<String, dynamic> json) {
id = json['id'];
title = json['title'];
cover = json['cover'];
pic = json['cover'];
duration = json['duration'];
pubtime = json['pubtime'];
bvid = json['bvid'];
upper = json['upper'];
cntInfo = json['cnt_info'];
enableVt = json['enable_vt'];
vtDisplay = json['vt_display'];
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['cover'] = cover;
data['duration'] = duration;
data['pubtime'] = pubtime;
data['bvid'] = bvid;
data['upper'] = upper;
data['cnt_info'] = cntInfo;
data['enable_vt'] = enableVt;
data['vt_display'] = vtDisplay;
return data;
}
}
class DetailInfo {
int? id;
int? seasonType;
String? title;
String? cover;
Map? upper;
Map? cntInfo;
int? mediaCount;
String? intro;
int? enableVt;
DetailInfo({
this.id,
this.seasonType,
this.title,
this.cover,
this.upper,
this.cntInfo,
this.mediaCount,
this.intro,
this.enableVt,
});
DetailInfo.fromJson(Map<String, dynamic> json) {
id = json['id'];
seasonType = json['season_type'];
title = json['title'];
cover = json['cover'];
upper = json['upper'];
cntInfo = json['cnt_info'];
mediaCount = json['media_count'];
intro = json['intro'];
enableVt = json['enable_vt'];
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['id'] = id;
data['season_type'] = seasonType;
data['title'] = title;
data['cover'] = cover;
data['upper'] = upper;
data['cnt_info'] = cntInfo;
data['media_count'] = mediaCount;
data['intro'] = intro;
data['enable_vt'] = enableVt;
return data;
}
}

View File

@ -0,0 +1,111 @@
class SubFolderModelData {
final int? count;
final List<SubFolderItemData>? list;
SubFolderModelData({
this.count,
this.list,
});
factory SubFolderModelData.fromJson(Map<String, dynamic> json) {
return SubFolderModelData(
count: json['count'],
list: json['list'] != null
? (json['list'] as List)
.map<SubFolderItemData>((i) => SubFolderItemData.fromJson(i))
.toList()
: null,
);
}
}
class SubFolderItemData {
final int? id;
final int? fid;
final int? mid;
final int? attr;
final String? title;
final String? cover;
final Upper? upper;
final int? coverType;
final String? intro;
final int? ctime;
final int? mtime;
final int? state;
final int? favState;
final int? mediaCount;
final int? viewCount;
final int? vt;
final int? playSwitch;
final int? type;
final String? link;
final String? bvid;
SubFolderItemData({
this.id,
this.fid,
this.mid,
this.attr,
this.title,
this.cover,
this.upper,
this.coverType,
this.intro,
this.ctime,
this.mtime,
this.state,
this.favState,
this.mediaCount,
this.viewCount,
this.vt,
this.playSwitch,
this.type,
this.link,
this.bvid,
});
factory SubFolderItemData.fromJson(Map<String, dynamic> json) {
return SubFolderItemData(
id: json['id'],
fid: json['fid'],
mid: json['mid'],
attr: json['attr'],
title: json['title'],
cover: json['cover'],
upper: json['upper'] != null ? Upper.fromJson(json['upper']) : null,
coverType: json['cover_type'],
intro: json['intro'],
ctime: json['ctime'],
mtime: json['mtime'],
state: json['state'],
favState: json['fav_state'],
mediaCount: json['media_count'],
viewCount: json['view_count'],
vt: json['vt'],
playSwitch: json['play_switch'],
type: json['type'],
link: json['link'],
bvid: json['bvid'],
);
}
}
class Upper {
final int? mid;
final String? name;
final String? face;
Upper({
this.mid,
this.name,
this.face,
});
factory Upper.fromJson(Map<String, dynamic> json) {
return Upper(
mid: json['mid'],
name: json['name'],
face: json['face'],
);
}
}

View File

@ -0,0 +1,120 @@
class EmoteModelData {
final List<PackageItem>? packages;
EmoteModelData({
required this.packages,
});
factory EmoteModelData.fromJson(Map<String, dynamic> jsonRes) {
final List<PackageItem>? packages =
jsonRes['packages'] is List ? <PackageItem>[] : null;
if (packages != null) {
for (final dynamic item in jsonRes['packages']!) {
if (item != null) {
try {
packages.add(PackageItem.fromJson(item));
} catch (_) {}
}
}
}
return EmoteModelData(
packages: packages,
);
}
}
class PackageItem {
final int? id;
final String? text;
final String? url;
final int? mtime;
final int? type;
final int? attr;
final Meta? meta;
final List<Emote>? emote;
PackageItem({
required this.id,
required this.text,
required this.url,
required this.mtime,
required this.type,
required this.attr,
required this.meta,
required this.emote,
});
factory PackageItem.fromJson(Map<String, dynamic> jsonRes) {
final List<Emote>? emote = jsonRes['emote'] is List ? <Emote>[] : null;
if (emote != null) {
for (final dynamic item in jsonRes['emote']!) {
if (item != null) {
try {
emote.add(Emote.fromJson(item));
} catch (_) {}
}
}
}
return PackageItem(
id: jsonRes['id'],
text: jsonRes['text'],
url: jsonRes['url'],
mtime: jsonRes['mtime'],
type: jsonRes['type'],
attr: jsonRes['attr'],
meta: Meta.fromJson(jsonRes['meta']),
emote: emote,
);
}
}
class Meta {
final int? size;
final List<String>? suggest;
Meta({
required this.size,
required this.suggest,
});
factory Meta.fromJson(Map<String, dynamic> jsonRes) => Meta(
size: jsonRes['size'],
suggest: jsonRes['suggest'] is List ? <String>[] : null,
);
}
class Emote {
final int? id;
final int? packageId;
final String? text;
final String? url;
final int? mtime;
final int? type;
final int? attr;
final Meta? meta;
final dynamic activity;
Emote({
required this.id,
required this.packageId,
required this.text,
required this.url,
required this.mtime,
required this.type,
required this.attr,
required this.meta,
required this.activity,
});
factory Emote.fromJson(Map<String, dynamic> jsonRes) => Emote(
id: jsonRes['id'],
packageId: jsonRes['package_id'],
text: jsonRes['text'],
url: jsonRes['url'],
mtime: jsonRes['mtime'],
type: jsonRes['type'],
attr: jsonRes['attr'],
meta: Meta.fromJson(jsonRes['meta']),
activity: jsonRes['activity'],
);
}

View File

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../http/reply.dart';
import '../../models/video/reply/emote.dart';
class EmotePanelController extends GetxController
with GetTickerProviderStateMixin {
late List<PackageItem> emotePackage;
late TabController tabController;
Future getEmote() async {
var res = await ReplyHttp.getEmoteList(business: 'reply');
if (res['status']) {
emotePackage = res['data'].packages;
tabController = TabController(length: emotePackage.length, vsync: this);
}
return res;
}
}

View File

@ -0,0 +1,4 @@
library emote;
export './controller.dart';
export './view.dart';

116
lib/pages/emote/view.dart Normal file
View File

@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../models/video/reply/emote.dart';
import 'controller.dart';
class EmotePanel extends StatefulWidget {
final Function onChoose;
const EmotePanel({super.key, required this.onChoose});
@override
State<EmotePanel> createState() => _EmotePanelState();
}
class _EmotePanelState extends State<EmotePanel>
with AutomaticKeepAliveClientMixin {
final EmotePanelController _emotePanelController =
Get.put(EmotePanelController());
late Future _futureBuilderFuture;
@override
bool get wantKeepAlive => true;
@override
void initState() {
_futureBuilderFuture = _emotePanelController.getEmote();
super.initState();
}
@override
Widget build(BuildContext context) {
super.build(context);
return FutureBuilder(
future: _futureBuilderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data as Map;
if (data['status']) {
List<PackageItem> emotePackage =
_emotePanelController.emotePackage;
return Column(
children: [
Expanded(
child: TabBarView(
controller: _emotePanelController.tabController,
children: emotePackage.map(
(e) {
int size = e.emote!.first.meta!.size!;
int type = e.type!;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 0),
child: GridView.builder(
gridDelegate:
SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: size == 1 ? 40 : 60,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: e.emote!.length,
itemBuilder: (context, index) {
return Material(
color: Colors.transparent,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
child: InkWell(
onTap: () {
widget.onChoose(e, e.emote![index]);
},
child: Padding(
padding: const EdgeInsets.all(3),
child: type == 4
? Text(
e.emote![index].text!,
overflow: TextOverflow.clip,
maxLines: 1,
)
: Image.network(
e.emote![index].url!,
width: size * 38,
height: size * 38,
),
),
),
);
},
),
);
},
).toList(),
)),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
TabBar(
controller: _emotePanelController.tabController,
dividerColor: Colors.transparent,
isScrollable: true,
tabs: _emotePanelController.emotePackage
.map((e) => Tab(text: e.text))
.toList(),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 20),
],
);
} else {
return Center(child: Text(data['msg']));
}
} else {
return const Center(child: Text('加载中...'));
}
});
}
}

View File

@ -24,7 +24,7 @@ class FavController extends GetxController {
if (!hasMore.value) { if (!hasMore.value) {
return; return;
} }
var res = await await UserHttp.userfavFolder( var res = await UserHttp.userfavFolder(
pn: currentPage, pn: currentPage,
ps: pageSize, ps: pageSize,
mid: userInfo!.mid!, mid: userInfo!.mid!,

View File

@ -34,7 +34,7 @@ class FavDetailController extends GetxController {
return; return;
} }
isLoadingMore = true; isLoadingMore = true;
var res = await await UserHttp.userFavFolderDetail( var res = await UserHttp.userFavFolderDetail(
pn: currentPage, pn: currentPage,
ps: 20, ps: 20,
mediaId: mediaId!, mediaId: mediaId!,

View File

@ -31,7 +31,6 @@ class _FavDetailPageState extends State<FavDetailPage> {
super.initState(); super.initState();
mediaId = Get.parameters['mediaId']!; mediaId = Get.parameters['mediaId']!;
_futureBuilderFuture = _favDetailController.queryUserFavFolderDetail(); _futureBuilderFuture = _favDetailController.queryUserFavFolderDetail();
mediaId = Get.parameters['mediaId']!;
titleStreamC = StreamController<bool>(); titleStreamC = StreamController<bool>();
_controller.addListener( _controller.addListener(
() { () {

View File

@ -70,10 +70,6 @@ class _HistoryPageState extends State<HistoryPage> {
child1: AppBar( child1: AppBar(
titleSpacing: 0, titleSpacing: 0,
centerTitle: false, centerTitle: false,
leading: IconButton(
onPressed: () => Get.back(),
icon: const Icon(Icons.arrow_back_outlined),
),
title: Text( title: Text(
'观看记录', '观看记录',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,

View File

@ -75,41 +75,45 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Stack( body: Stack(
children: [ children: [
// Obx( Positioned(
// () => Positioned.fill( left: 0,
// child: Opacity( right: 0,
// opacity: 0.8, bottom: 0,
// child: _liveRoomController
// .roomInfoH5.value.roomInfo?.appBackground !=
// '' &&
// _liveRoomController
// .roomInfoH5.value.roomInfo?.appBackground !=
// null
// ? NetworkImgLayer(
// width: Get.width,
// height: Get.height,
// src: _liveRoomController
// .roomInfoH5.value.roomInfo?.appBackground ??
// '',
// )
// : Image.asset(
// 'assets/images/live/default_bg.webp',
// width: Get.width,
// height: Get.height,
// ),
// ),
// ),
// ),
Positioned.fill(
child: Opacity( child: Opacity(
opacity: 0.8, opacity: 0.8,
child: Image.asset( child: Image.asset(
'assets/images/live/default_bg.webp', 'assets/images/live/default_bg.webp',
width: Get.width, fit: BoxFit.cover,
height: Get.height, // width: Get.width,
// height: Get.height,
), ),
), ),
), ),
Obx(
() => Positioned(
left: 0,
right: 0,
bottom: 0,
child: _liveRoomController
.roomInfoH5.value.roomInfo?.appBackground !=
'' &&
_liveRoomController
.roomInfoH5.value.roomInfo?.appBackground !=
null
? Opacity(
opacity: 0.8,
child: NetworkImgLayer(
width: Get.width,
height: Get.height,
type: 'bg',
src: _liveRoomController
.roomInfoH5.value.roomInfo?.appBackground ??
'',
),
)
: const SizedBox(),
),
),
Column( Column(
children: [ children: [
AppBar( AppBar(

View File

@ -28,6 +28,11 @@ class MediaController extends GetxController {
'title': '我的收藏', 'title': '我的收藏',
'onTap': () => Get.toNamed('/fav'), 'onTap': () => Get.toNamed('/fav'),
}, },
{
'icon': Icons.subscriptions_outlined,
'title': '我的订阅',
'onTap': () => Get.toNamed('/subscription'),
},
{ {
'icon': Icons.watch_later_outlined, 'icon': Icons.watch_later_outlined,
'title': '稍后再看', 'title': '稍后再看',

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:pilipala/http/user.dart';
import 'package:pilipala/models/user/info.dart';
import 'package:pilipala/utils/storage.dart';
import '../../models/user/sub_folder.dart';
class SubController extends GetxController {
final ScrollController scrollController = ScrollController();
Rx<SubFolderModelData> subFolderData = SubFolderModelData().obs;
Box userInfoCache = GStrorage.userInfo;
UserInfoData? userInfo;
int currentPage = 1;
int pageSize = 20;
RxBool hasMore = true.obs;
Future<dynamic> querySubFolder({type = 'init'}) async {
userInfo = userInfoCache.get('userInfoCache');
if (userInfo == null) {
return {'status': false, 'msg': '账号未登录'};
}
var res = await UserHttp.userSubFolder(
pn: currentPage,
ps: pageSize,
mid: userInfo!.mid!,
);
if (res['status']) {
if (type == 'init') {
subFolderData.value = res['data'];
} else {
if (res['data'].list.isNotEmpty) {
subFolderData.value.list!.addAll(res['data'].list);
subFolderData.update((val) {});
}
}
currentPage++;
} else {
SmartDialog.showToast(res['msg']);
}
return res;
}
Future onLoad() async {
querySubFolder(type: 'onload');
}
}

View File

@ -0,0 +1,4 @@
library sub;
export './controller.dart';
export './view.dart';

View File

@ -0,0 +1,85 @@
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/pages/fav/widgets/item.dart';
import 'controller.dart';
import 'widgets/item.dart';
class SubPage extends StatefulWidget {
const SubPage({super.key});
@override
State<SubPage> createState() => _SubPageState();
}
class _SubPageState extends State<SubPage> {
final SubController _subController = Get.put(SubController());
late Future _futureBuilderFuture;
late ScrollController scrollController;
@override
void initState() {
super.initState();
_futureBuilderFuture = _subController.querySubFolder();
scrollController = _subController.scrollController;
scrollController.addListener(
() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 300) {
EasyThrottle.throttle('history', const Duration(seconds: 1), () {
_subController.onLoad();
});
}
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: false,
titleSpacing: 0,
title: Text(
'我的订阅',
style: Theme.of(context).textTheme.titleMedium,
),
),
body: FutureBuilder(
future: _futureBuilderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map? data = snapshot.data;
if (data != null && data['status']) {
return Obx(
() => ListView.builder(
controller: scrollController,
itemCount: _subController.subFolderData.value.list!.length,
itemBuilder: (context, index) {
return SubItem(
subFolderItem:
_subController.subFolderData.value.list![index]);
},
),
);
} else {
return CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [
HttpError(
errMsg: data?['msg'],
fn: () => setState(() {}),
),
],
);
}
} else {
// 骨架屏
return const Text('请求中');
}
},
),
);
}
}

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/constants.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/utils/utils.dart';
import '../../../models/user/sub_folder.dart';
class SubItem extends StatelessWidget {
final SubFolderItemData subFolderItem;
const SubItem({super.key, required this.subFolderItem});
@override
Widget build(BuildContext context) {
String heroTag = Utils.makeHeroTag(subFolderItem.id);
return InkWell(
onTap: () => Get.toNamed(
'/subDetail',
arguments: subFolderItem,
parameters: {
'heroTag': heroTag,
'seasonId': subFolderItem.id.toString(),
},
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 7, 12, 7),
child: LayoutBuilder(
builder: (context, boxConstraints) {
double width =
(boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2;
return SizedBox(
height: width / StyleString.aspectRatio,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: StyleString.aspectRatio,
child: LayoutBuilder(
builder: (context, boxConstraints) {
double maxWidth = boxConstraints.maxWidth;
double maxHeight = boxConstraints.maxHeight;
return Hero(
tag: heroTag,
child: NetworkImgLayer(
src: subFolderItem.cover,
width: maxWidth,
height: maxHeight,
),
);
},
),
),
VideoContent(subFolderItem: subFolderItem)
],
),
);
},
),
),
);
}
}
class VideoContent extends StatelessWidget {
final SubFolderItemData subFolderItem;
const VideoContent({super.key, required this.subFolderItem});
@override
Widget build(BuildContext context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 2, 6, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subFolderItem.title!,
textAlign: TextAlign.start,
style: const TextStyle(
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
),
),
const SizedBox(height: 2),
Text(
'合集 UP主${subFolderItem.upper!.name!}',
textAlign: TextAlign.start,
style: TextStyle(
fontSize: Theme.of(context).textTheme.labelMedium!.fontSize,
color: Theme.of(context).colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
'${subFolderItem.mediaCount}个视频',
textAlign: TextAlign.start,
style: TextStyle(
fontSize: Theme.of(context).textTheme.labelMedium!.fontSize,
color: Theme.of(context).colorScheme.outline,
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,60 @@
import 'package:get/get.dart';
import 'package:pilipala/http/user.dart';
import '../../models/user/sub_detail.dart';
import '../../models/user/sub_folder.dart';
class SubDetailController extends GetxController {
late SubFolderItemData item;
late int seasonId;
late String heroTag;
int currentPage = 1;
bool isLoadingMore = false;
Rx<DetailInfo> subInfo = DetailInfo().obs;
RxList<SubDetailMediaItem> subList = <SubDetailMediaItem>[].obs;
RxString loadingText = '加载中...'.obs;
int mediaCount = 0;
@override
void onInit() {
item = Get.arguments;
if (Get.parameters.keys.isNotEmpty) {
seasonId = int.parse(Get.parameters['seasonId']!);
heroTag = Get.parameters['heroTag']!;
}
super.onInit();
}
Future<dynamic> queryUserSubFolderDetail({type = 'init'}) async {
if (type == 'onLoad' && subList.length >= mediaCount) {
loadingText.value = '没有更多了';
return;
}
isLoadingMore = true;
var res = await UserHttp.userSubFolderDetail(
seasonId: seasonId,
ps: 20,
pn: currentPage,
);
if (res['status']) {
subInfo.value = res['data'].info;
if (currentPage == 1 && type == 'init') {
subList.value = res['data'].medias;
mediaCount = res['data'].info.mediaCount;
} else if (type == 'onLoad') {
subList.addAll(res['data'].medias);
}
if (subList.length >= mediaCount) {
loadingText.value = '没有更多了';
}
}
currentPage += 1;
isLoadingMore = false;
return res;
}
onLoad() {
queryUserSubFolderDetail(type: 'onLoad');
}
}

View File

@ -0,0 +1,4 @@
library sub_detail;
export './controller.dart';
export './view.dart';

View File

@ -0,0 +1,257 @@
import 'dart:async';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/skeleton/video_card_h.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/common/widgets/no_data.dart';
import '../../models/user/sub_folder.dart';
import '../../utils/utils.dart';
import 'controller.dart';
import 'widget/sub_video_card.dart';
class SubDetailPage extends StatefulWidget {
const SubDetailPage({super.key});
@override
State<SubDetailPage> createState() => _SubDetailPageState();
}
class _SubDetailPageState extends State<SubDetailPage> {
late final ScrollController _controller = ScrollController();
final SubDetailController _subDetailController =
Get.put(SubDetailController());
late StreamController<bool> titleStreamC; // a
late Future _futureBuilderFuture;
late String seasonId;
@override
void initState() {
super.initState();
seasonId = Get.parameters['seasonId']!;
_futureBuilderFuture = _subDetailController.queryUserSubFolderDetail();
titleStreamC = StreamController<bool>();
_controller.addListener(
() {
if (_controller.offset > 160) {
titleStreamC.add(true);
} else if (_controller.offset <= 160) {
titleStreamC.add(false);
}
if (_controller.position.pixels >=
_controller.position.maxScrollExtent - 200) {
EasyThrottle.throttle('subDetail', const Duration(seconds: 1), () {
_subDetailController.onLoad();
});
}
},
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
controller: _controller,
slivers: [
SliverAppBar(
expandedHeight: 260 - MediaQuery.of(context).padding.top,
pinned: true,
titleSpacing: 0,
title: StreamBuilder(
stream: titleStreamC.stream,
initialData: false,
builder: (context, AsyncSnapshot snapshot) {
return AnimatedOpacity(
opacity: snapshot.data ? 1 : 0,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 500),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_subDetailController.item.title!,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${_subDetailController.item.mediaCount!}条视频',
style: Theme.of(context).textTheme.labelMedium,
)
],
)
],
),
);
},
),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.2),
),
),
),
padding: EdgeInsets.only(
top: kTextTabBarHeight +
MediaQuery.of(context).padding.top +
30,
left: 20,
right: 20),
child: SizedBox(
height: 200,
child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: _subDetailController.heroTag,
child: NetworkImgLayer(
width: 180,
height: 110,
src: _subDetailController.item.cover,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
_subDetailController.item.title!,
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.titleMedium!
.fontSize,
fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () {
SubFolderItemData item =
_subDetailController.item;
Get.toNamed(
'/member?mid=${item.upper!.mid}',
arguments: {
'face': item.upper!.face,
},
);
},
child: Text(
_subDetailController.item.upper!.name!,
style: TextStyle(
color:
Theme.of(context).colorScheme.primary),
),
),
const SizedBox(height: 4),
Text(
'${Utils.numFormat(_subDetailController.item.viewCount)}次播放',
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.labelSmall!
.fontSize,
color: Theme.of(context).colorScheme.outline),
),
],
),
),
],
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 15, bottom: 8, left: 14),
child: Obx(
() => Text(
'${_subDetailController.subList.length}条视频',
style: TextStyle(
fontSize:
Theme.of(context).textTheme.labelMedium!.fontSize,
color: Theme.of(context).colorScheme.outline,
letterSpacing: 1),
),
),
),
),
FutureBuilder(
future: _futureBuilderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data;
if (data['status']) {
if (_subDetailController.item.mediaCount == 0) {
return const NoData();
} else {
List subList = _subDetailController.subList;
return Obx(
() => subList.isEmpty
? const SliverToBoxAdapter(child: SizedBox())
: SliverList(
delegate:
SliverChildBuilderDelegate((context, index) {
return SubVideoCardH(
videoItem: subList[index],
);
}, childCount: subList.length),
),
);
}
} else {
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return const VideoCardHSkeleton();
}, childCount: 10),
);
}
},
),
SliverToBoxAdapter(
child: Container(
height: MediaQuery.of(context).padding.bottom + 60,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom),
child: Center(
child: Obx(
() => Text(
_subDetailController.loadingText.value,
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
fontSize: 13),
),
),
),
),
)
],
),
);
}
}

View File

@ -0,0 +1,168 @@
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/common/constants.dart';
import 'package:pilipala/common/widgets/stat/danmu.dart';
import 'package:pilipala/common/widgets/stat/view.dart';
import 'package:pilipala/http/search.dart';
import 'package:pilipala/models/common/search_type.dart';
import 'package:pilipala/utils/utils.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import '../../../common/widgets/badge.dart';
import '../../../models/user/sub_detail.dart';
// 收藏视频卡片 - 水平布局
class SubVideoCardH extends StatelessWidget {
final SubDetailMediaItem videoItem;
final int? searchType;
const SubVideoCardH({
Key? key,
required this.videoItem,
this.searchType,
}) : super(key: key);
@override
Widget build(BuildContext context) {
int id = videoItem.id!;
String bvid = videoItem.bvid!;
String heroTag = Utils.makeHeroTag(id);
return InkWell(
onTap: () async {
int cid = await SearchHttp.ab2c(bvid: bvid);
Map<String, String> parameters = {
'bvid': bvid,
'cid': cid.toString(),
};
Get.toNamed('/video', parameters: parameters, arguments: {
'videoItem': videoItem,
'heroTag': heroTag,
'videoType': SearchType.video,
});
},
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
StyleString.safeSpace, 5, StyleString.safeSpace, 5),
child: LayoutBuilder(
builder: (context, boxConstraints) {
double width =
(boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2;
return SizedBox(
height: width / StyleString.aspectRatio,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: StyleString.aspectRatio,
child: LayoutBuilder(
builder: (context, boxConstraints) {
double maxWidth = boxConstraints.maxWidth;
double maxHeight = boxConstraints.maxHeight;
return Stack(
children: [
Hero(
tag: heroTag,
child: NetworkImgLayer(
src: videoItem.cover,
width: maxWidth,
height: maxHeight,
),
),
PBadge(
text: Utils.timeFormat(videoItem.duration!),
right: 6.0,
bottom: 6.0,
type: 'gray',
),
// if (videoItem.ogv != null) ...[
// PBadge(
// text: videoItem.ogv['type_name'],
// top: 6.0,
// right: 6.0,
// bottom: null,
// left: null,
// ),
// ],
],
);
},
),
),
VideoContent(
videoItem: videoItem,
searchType: searchType,
)
],
),
);
},
),
),
],
),
);
}
}
class VideoContent extends StatelessWidget {
final dynamic videoItem;
final int? searchType;
const VideoContent({
super.key,
required this.videoItem,
this.searchType,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 2, 6, 0),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
videoItem.title,
textAlign: TextAlign.start,
style: const TextStyle(
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
Text(
Utils.dateFormat(videoItem.pubtime),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.outline),
),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
StatView(
theme: 'gray',
view: videoItem.cntInfo['play'],
),
const SizedBox(width: 8),
StatDanMu(
theme: 'gray', danmu: videoItem.cntInfo['danmaku']),
const Spacer(),
],
),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
class ToolbarIconButton extends StatelessWidget {
final VoidCallback onPressed;
final Icon icon;
final String toolbarType;
final bool selected;
const ToolbarIconButton({
super.key,
required this.onPressed,
required this.icon,
required this.toolbarType,
required this.selected,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: onPressed,
icon: icon,
highlightColor: Theme.of(context).colorScheme.secondaryContainer,
color: selected
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.outline,
style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.zero),
backgroundColor: MaterialStateProperty.resolveWith((states) {
return selected
? Theme.of(context).colorScheme.secondaryContainer
: null;
}),
),
),
);
}
}

View File

@ -4,9 +4,13 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:pilipala/http/video.dart'; import 'package:pilipala/http/video.dart';
import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/models/common/reply_type.dart';
import 'package:pilipala/models/video/reply/emote.dart';
import 'package:pilipala/models/video/reply/item.dart'; import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/emote/index.dart';
import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/feed_back.dart';
import 'toolbar_icon_button.dart';
class VideoReplyNewDialog extends StatefulWidget { class VideoReplyNewDialog extends StatefulWidget {
final int? oid; final int? oid;
final int? root; final int? root;
@ -32,6 +36,10 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
final TextEditingController _replyContentController = TextEditingController(); final TextEditingController _replyContentController = TextEditingController();
final FocusNode replyContentFocusNode = FocusNode(); final FocusNode replyContentFocusNode = FocusNode();
final GlobalKey _formKey = GlobalKey<FormState>(); final GlobalKey _formKey = GlobalKey<FormState>();
late double emoteHeight = 0.0;
double keyboardHeight = 0.0; // 键盘高度
final _debouncer = Debouncer(milliseconds: 200); // 设置延迟时间
String toolbarType = 'input';
@override @override
void initState() { void initState() {
@ -42,6 +50,8 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
// 自动聚焦 // 自动聚焦
_autoFocus(); _autoFocus();
// 监听聚焦状态
_focuslistener();
} }
_autoFocus() async { _autoFocus() async {
@ -51,6 +61,16 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
} }
} }
_focuslistener() {
replyContentFocusNode.addListener(() {
if (replyContentFocusNode.hasFocus) {
setState(() {
toolbarType = 'input';
});
}
});
}
Future submitReplyAdd() async { Future submitReplyAdd() async {
feedBack(); feedBack();
String message = _replyContentController.text; String message = _replyContentController.text;
@ -73,18 +93,49 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
} }
} }
void onChooseEmote(PackageItem package, Emote emote) {
final int cursorPosition = _replyContentController.selection.baseOffset;
final String currentText = _replyContentController.text;
final String newText = currentText.substring(0, cursorPosition) +
emote.text! +
currentText.substring(cursorPosition);
_replyContentController.value = TextEditingValue(
text: newText,
selection:
TextSelection.collapsed(offset: cursorPosition + emote.text!.length),
);
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
WidgetsBinding.instance.addPostFrameCallback((_) {
// 键盘高度
final viewInsets = EdgeInsets.fromViewPadding(
View.of(context).viewInsets, View.of(context).devicePixelRatio);
_debouncer.run(() {
if (mounted) {
if (keyboardHeight == 0 && emoteHeight == 0) {
setState(() {
emoteHeight = keyboardHeight =
keyboardHeight == 0.0 ? viewInsets.bottom : keyboardHeight;
});
}
}
});
});
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_replyContentController.dispose(); _replyContentController.dispose();
replyContentFocusNode.removeListener(() {});
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
double keyboardHeight = EdgeInsets.fromViewPadding(
View.of(context).viewInsets, View.of(context).devicePixelRatio)
.bottom;
return Container( return Container(
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -137,27 +188,32 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
SizedBox( ToolbarIconButton(
width: 36, onPressed: () {
height: 36, if (toolbarType == 'emote') {
child: IconButton( setState(() {
onPressed: () { toolbarType = 'input';
FocusScope.of(context) });
.requestFocus(replyContentFocusNode); }
}, FocusScope.of(context).requestFocus(replyContentFocusNode);
icon: Icon(Icons.keyboard, },
size: 22, icon: const Icon(Icons.keyboard, size: 22),
color: Theme.of(context).colorScheme.onBackground), toolbarType: toolbarType,
highlightColor: selected: toolbarType == 'input',
Theme.of(context).colorScheme.onInverseSurface, ),
style: ButtonStyle( const SizedBox(width: 20),
padding: MaterialStateProperty.all(EdgeInsets.zero), ToolbarIconButton(
backgroundColor: onPressed: () {
MaterialStateProperty.resolveWith((states) { if (toolbarType == 'input') {
return Theme.of(context).highlightColor; setState(() {
}), toolbarType = 'emote';
), });
), }
FocusScope.of(context).unfocus();
},
icon: const Icon(Icons.emoji_emotions, size: 22),
toolbarType: toolbarType,
selected: toolbarType == 'emote',
), ),
const Spacer(), const Spacer(),
TextButton( TextButton(
@ -170,7 +226,10 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
height: keyboardHeight, height: toolbarType == 'input' ? keyboardHeight : emoteHeight,
child: EmotePanel(
onChoose: (package, emote) => onChooseEmote(package, emote),
),
), ),
), ),
], ],
@ -178,3 +237,22 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
); );
} }
} }
typedef DebounceCallback = void Function();
class Debouncer {
DebounceCallback? callback;
final int? milliseconds;
Timer? _timer;
Debouncer({this.milliseconds});
run(DebounceCallback callback) {
if (_timer != null) {
_timer!.cancel();
}
_timer = Timer(Duration(milliseconds: milliseconds!), () {
callback();
});
}
}

View File

@ -108,9 +108,9 @@ class _WhisperPageState extends State<WhisperPage> {
future: _futureBuilderFuture, future: _futureBuilderFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) { if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data as Map; Map? data = snapshot.data;
if (data['status']) { if (data != null && data['status']) {
List sessionList = _whisperController.sessionList; RxList sessionList = _whisperController.sessionList;
return Obx( return Obx(
() => sessionList.isEmpty () => sessionList.isEmpty
? const SizedBox() ? const SizedBox()
@ -121,33 +121,35 @@ class _WhisperPageState extends State<WhisperPage> {
const NeverScrollableScrollPhysics(), const NeverScrollableScrollPhysics(),
itemBuilder: (_, int i) { itemBuilder: (_, int i) {
return ListTile( return ListTile(
onTap: () => Get.toNamed( onTap: () {
'/whisperDetail', sessionList[i].unreadCount = 0;
parameters: { sessionList.refresh();
'talkerId': sessionList[i] Get.toNamed(
.talkerId '/whisperDetail',
.toString(), parameters: {
'name': sessionList[i] 'talkerId': sessionList[i]
.accountInfo .talkerId
.name, .toString(),
'face': sessionList[i] 'name': sessionList[i]
.accountInfo .accountInfo
.face, .name,
'mid': sessionList[i] 'face': sessionList[i]
.accountInfo .accountInfo
.mid .face,
.toString(), 'mid': sessionList[i]
}, .accountInfo
), .mid
.toString(),
},
);
},
leading: Badge( leading: Badge(
isLabelVisible: false, isLabelVisible:
backgroundColor: Theme.of(context) sessionList[i].unreadCount > 0,
.colorScheme
.primary,
label: Text(sessionList[i] label: Text(sessionList[i]
.unreadCount .unreadCount
.toString()), .toString()),
alignment: Alignment.bottomRight, alignment: Alignment.topRight,
child: NetworkImgLayer( child: NetworkImgLayer(
width: 45, width: 45,
height: 45, height: 45,
@ -160,20 +162,26 @@ class _WhisperPageState extends State<WhisperPage> {
title: Text( title: Text(
sessionList[i].accountInfo.name), sessionList[i].accountInfo.name),
subtitle: Text( subtitle: Text(
sessionList[i] sessionList[i].lastMsg.content !=
.lastMsg null &&
.content['text'] ?? sessionList[i]
sessionList[i] .lastMsg
.lastMsg .content !=
.content['content'] ?? ''
sessionList[i] ? (sessionList[i]
.lastMsg
.content['title'] ??
sessionList[i]
.lastMsg .lastMsg
.content[ .content['text'] ??
'reply_content'] ?? sessionList[i]
'', .lastMsg
.content['content'] ??
sessionList[i]
.lastMsg
.content['title'] ??
sessionList[i]
.lastMsg
.content[
'reply_content'])
: '不支持的消息类型',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context) style: Theme.of(context)
@ -210,7 +218,9 @@ class _WhisperPageState extends State<WhisperPage> {
); );
} else { } else {
// 请求错误 // 请求错误
return const SizedBox(); return Center(
child: Text(data?['msg'] ?? '请求异常'),
);
} }
} else { } else {
// 骨架屏 // 骨架屏

View File

@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:pilipala/http/msg.dart'; import 'package:pilipala/http/msg.dart';
import 'package:pilipala/models/msg/session.dart'; import 'package:pilipala/models/msg/session.dart';
import '../../utils/feed_back.dart';
import '../../utils/storage.dart';
class WhisperDetailController extends GetxController { class WhisperDetailController extends GetxController {
late int talkerId; late int talkerId;
@ -11,6 +15,8 @@ class WhisperDetailController extends GetxController {
RxList<MessageItem> messageList = <MessageItem>[].obs; RxList<MessageItem> messageList = <MessageItem>[].obs;
//表情转换图片规则 //表情转换图片规则
List<dynamic>? eInfos; List<dynamic>? eInfos;
final TextEditingController replyContentController = TextEditingController();
Box userInfoCache = GStrorage.userInfo;
@override @override
void onInit() { void onInit() {
@ -25,10 +31,51 @@ class WhisperDetailController extends GetxController {
var res = await MsgHttp.sessionMsg(talkerId: talkerId); var res = await MsgHttp.sessionMsg(talkerId: talkerId);
if (res['status']) { if (res['status']) {
messageList.value = res['data'].messages; messageList.value = res['data'].messages;
if (messageList.isNotEmpty && res['data'].eInfos != null) { if (messageList.isNotEmpty) {
eInfos = res['data'].eInfos; ackSessionMsg();
if (res['data'].eInfos != null) {
eInfos = res['data'].eInfos;
}
} }
} else {
SmartDialog.showToast(res['msg']);
} }
return res; return res;
} }
// 消息标记已读
Future ackSessionMsg() async {
if (messageList.isEmpty) {
return;
}
await MsgHttp.ackSessionMsg(
talkerId: talkerId,
ackSeqno: messageList.last.msgSeqno,
);
}
Future sendMsg() async {
feedBack();
String message = replyContentController.text;
final userInfo = userInfoCache.get('userInfoCache');
if (userInfo == null) {
SmartDialog.showToast('请先登录');
return;
}
if (message == '') {
SmartDialog.showToast('请输入内容');
return;
}
var result = await MsgHttp.sendMsg(
senderUid: userInfo.mid,
receiverId: int.parse(mid),
content: {'content': message},
msgType: 1,
);
if (result['status']) {
SmartDialog.showToast('发送成功');
} else {
SmartDialog.showToast(result['msg']);
}
}
} }

View File

@ -1,9 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/pages/emote/index.dart';
import 'package:pilipala/pages/whisper_detail/controller.dart'; import 'package:pilipala/pages/whisper_detail/controller.dart';
import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/feed_back.dart';
import '../../utils/storage.dart';
import 'widget/chat_item.dart'; import 'widget/chat_item.dart';
class WhisperDetailPage extends StatefulWidget { class WhisperDetailPage extends StatefulWidget {
@ -13,15 +16,63 @@ class WhisperDetailPage extends StatefulWidget {
State<WhisperDetailPage> createState() => _WhisperDetailPageState(); State<WhisperDetailPage> createState() => _WhisperDetailPageState();
} }
class _WhisperDetailPageState extends State<WhisperDetailPage> { class _WhisperDetailPageState extends State<WhisperDetailPage>
with WidgetsBindingObserver {
final WhisperDetailController _whisperDetailController = final WhisperDetailController _whisperDetailController =
Get.put(WhisperDetailController()); Get.put(WhisperDetailController());
late Future _futureBuilderFuture; late Future _futureBuilderFuture;
late TextEditingController _replyContentController;
final FocusNode replyContentFocusNode = FocusNode();
final _debouncer = Debouncer(milliseconds: 200); // 设置延迟时间
late double emoteHeight = 0.0;
double keyboardHeight = 0.0; // 键盘高度
String toolbarType = 'input';
Box userInfoCache = GStrorage.userInfo;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_futureBuilderFuture = _whisperDetailController.querySessionMsg(); _futureBuilderFuture = _whisperDetailController.querySessionMsg();
_replyContentController = _whisperDetailController.replyContentController;
_focuslistener();
}
_focuslistener() {
replyContentFocusNode.addListener(() {
if (replyContentFocusNode.hasFocus) {
setState(() {
toolbarType = 'input';
});
}
});
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
WidgetsBinding.instance.addPostFrameCallback((_) {
// 键盘高度
final viewInsets = EdgeInsets.fromViewPadding(
View.of(context).viewInsets, View.of(context).devicePixelRatio);
_debouncer.run(() {
if (mounted) {
if (keyboardHeight == 0) {
setState(() {
emoteHeight = keyboardHeight =
keyboardHeight == 0.0 ? viewInsets.bottom : keyboardHeight;
});
}
}
});
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
replyContentFocusNode.removeListener(() {});
super.dispose();
} }
@override @override
@ -89,55 +140,63 @@ class _WhisperDetailPageState extends State<WhisperDetailPage> {
), ),
), ),
), ),
body: FutureBuilder( body: GestureDetector(
future: _futureBuilderFuture, onTap: () {
builder: (BuildContext context, snapshot) { FocusScope.of(context).unfocus();
if (snapshot.connectionState == ConnectionState.done) { setState(() {
if (snapshot.data == null) { keyboardHeight = 0;
return const SizedBox(); });
}
final Map data = snapshot.data as Map;
if (data['status']) {
List messageList = _whisperDetailController.messageList;
return Obx(
() => messageList.isEmpty
? const SizedBox()
: ListView.builder(
itemCount: messageList.length,
shrinkWrap: true,
reverse: true,
itemBuilder: (_, int i) {
if (i == 0) {
return Column(
children: [
ChatItem(
item: messageList[i],
e_infos: _whisperDetailController.eInfos),
const SizedBox(height: 12),
],
);
} else {
return ChatItem(
item: messageList[i],
e_infos: _whisperDetailController.eInfos);
}
},
),
);
} else {
// 请求错误
return const SizedBox();
}
} else {
// 骨架屏
return const SizedBox();
}
}, },
child: FutureBuilder(
future: _futureBuilderFuture,
builder: (BuildContext context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.data == null) {
return const SizedBox();
}
final Map data = snapshot.data as Map;
if (data['status']) {
List messageList = _whisperDetailController.messageList;
return Obx(
() => messageList.isEmpty
? const SizedBox()
: ListView.builder(
itemCount: messageList.length,
shrinkWrap: true,
reverse: true,
itemBuilder: (_, int i) {
if (i == 0) {
return Column(
children: [
ChatItem(
item: messageList[i],
e_infos: _whisperDetailController.eInfos),
const SizedBox(height: 12),
],
);
} else {
return ChatItem(
item: messageList[i],
e_infos: _whisperDetailController.eInfos);
}
},
),
);
} else {
// 请求错误
return const SizedBox();
}
} else {
// 骨架屏
return const SizedBox();
}
},
),
), ),
// resizeToAvoidBottomInset: true, // resizeToAvoidBottomInset: true,
bottomNavigationBar: Container( bottomNavigationBar: Container(
width: double.infinity, width: double.infinity,
height: MediaQuery.of(context).padding.bottom + 70, height: MediaQuery.of(context).padding.bottom + 70 + keyboardHeight,
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 8, left: 8,
right: 12, right: 12,
@ -152,48 +211,102 @@ class _WhisperDetailPageState extends State<WhisperDetailPage> {
), ),
), ),
), ),
child: Row( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// IconButton( Row(
// onPressed: () {}, mainAxisAlignment: MainAxisAlignment.center,
// icon: Icon( crossAxisAlignment: CrossAxisAlignment.start,
// Icons.add_circle_outline, children: [
// color: Theme.of(context).colorScheme.outline, // IconButton(
// ), // onPressed: () {},
// ), // icon: Icon(
IconButton( // Icons.add_circle_outline,
onPressed: () {}, // color: Theme.of(context).colorScheme.outline,
icon: Icon( // ),
Icons.emoji_emotions_outlined, // ),
color: Theme.of(context).colorScheme.outline, IconButton(
), onPressed: () {
), // if (toolbarType == 'input') {
Expanded( // setState(() {
child: Container( // toolbarType = 'emote';
height: 45, // });
decoration: BoxDecoration( // }
color: // FocusScope.of(context).unfocus();
Theme.of(context).colorScheme.primary.withOpacity(0.08), },
borderRadius: BorderRadius.circular(40.0), icon: Icon(
), Icons.emoji_emotions_outlined,
child: TextField( color: Theme.of(context).colorScheme.outline,
readOnly: true,
style: Theme.of(context).textTheme.titleMedium,
decoration: const InputDecoration(
border: InputBorder.none, // 移除默认边框
hintText: '开发中 ...', // 提示文本
contentPadding: EdgeInsets.symmetric(
horizontal: 16.0, vertical: 12.0), // 内边距
), ),
), ),
Expanded(
child: Container(
height: 45,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.08),
borderRadius: BorderRadius.circular(40.0),
),
child: TextField(
readOnly: true,
style: Theme.of(context).textTheme.titleMedium,
controller: _replyContentController,
autofocus: false,
focusNode: replyContentFocusNode,
decoration: const InputDecoration(
border: InputBorder.none, // 移除默认边框
hintText: '开发中 ...', // 提示文本
contentPadding: EdgeInsets.symmetric(
horizontal: 16.0, vertical: 12.0), // 内边距
),
),
),
),
IconButton(
// onPressed: _whisperDetailController.sendMsg,
onPressed: null,
icon: Icon(
Icons.send,
color: Theme.of(context).colorScheme.outline,
),
),
// const SizedBox(width: 16),
],
),
AnimatedSize(
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 300),
child: SizedBox(
width: double.infinity,
height: toolbarType == 'input' ? keyboardHeight : emoteHeight,
child: EmotePanel(
onChoose: (package, emote) => {},
),
), ),
), ),
const SizedBox(width: 16),
], ],
), ),
), ),
); );
} }
} }
typedef DebounceCallback = void Function();
class Debouncer {
DebounceCallback? callback;
final int? milliseconds;
Timer? _timer;
Debouncer({this.milliseconds});
run(DebounceCallback callback) {
if (_timer != null) {
_timer!.cancel();
}
_timer = Timer(Duration(milliseconds: milliseconds!), () {
callback();
});
}
}

View File

@ -204,7 +204,7 @@ class ChatItem extends StatelessWidget {
final int cid = await SearchHttp.ab2c(bvid: bvid); final int cid = await SearchHttp.ab2c(bvid: bvid);
final String heroTag = Utils.makeHeroTag(bvid); final String heroTag = Utils.makeHeroTag(bvid);
SmartDialog.dismiss<dynamic>().then( SmartDialog.dismiss<dynamic>().then(
(e) => Get.toNamed<dynamic>('/video?bvid=$bvid&cid=$cid', (e) => Get.toNamed<dynamic>('/video?bvid=$bvid&cid=$cid',
arguments: <String, String?>{ arguments: <String, String?>{
'pic': content['thumb'], 'pic': content['thumb'],
'heroTag': heroTag, 'heroTag': heroTag,
@ -352,7 +352,9 @@ class ChatItem extends StatelessWidget {
)); ));
default: default:
return Text( return Text(
content['content'] ?? content.toString(), content != null && content != ''
? (content['content'] ?? content.toString())
: '不支持的消息类型',
style: TextStyle( style: TextStyle(
letterSpacing: 0.6, letterSpacing: 0.6,
height: 1.5, height: 1.5,

View File

@ -44,6 +44,8 @@ import '../pages/setting/recommend_setting.dart';
import '../pages/setting/play_setting.dart'; import '../pages/setting/play_setting.dart';
import '../pages/setting/privacy_setting.dart'; import '../pages/setting/privacy_setting.dart';
import '../pages/setting/style_setting.dart'; import '../pages/setting/style_setting.dart';
import '../pages/subscription/index.dart';
import '../pages/subscription_detail/index.dart';
import '../pages/video/detail/index.dart'; import '../pages/video/detail/index.dart';
import '../pages/video/detail/reply_reply/index.dart'; import '../pages/video/detail/reply_reply/index.dart';
import '../pages/webview/index.dart'; import '../pages/webview/index.dart';
@ -160,6 +162,10 @@ class Routes {
CustomGetPage(name: '/logs', page: () => const LogsPage()), CustomGetPage(name: '/logs', page: () => const LogsPage()),
// 搜索关注 // 搜索关注
CustomGetPage(name: '/followSearch', page: () => const FollowSearchPage()), CustomGetPage(name: '/followSearch', page: () => const FollowSearchPage()),
// 订阅
CustomGetPage(name: '/subscription', page: () => const SubPage()),
// 订阅详情
CustomGetPage(name: '/subDetail', page: () => const SubDetailPage()),
]; ];
} }