feat: 搜索直播间、用户

This commit is contained in:
guozhigq
2023-06-20 22:52:47 +08:00
parent 7e7892aab2
commit c2f8f143f8
15 changed files with 801 additions and 59 deletions

View File

@ -0,0 +1,164 @@
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/pages/home/index.dart';
import 'package:pilipala/utils/utils.dart';
class LiveCard extends StatelessWidget {
var liveItem;
LiveCard({
Key? key,
required this.liveItem,
}) : super(key: key);
@override
Widget build(BuildContext context) {
String heroTag = Utils.makeHeroTag(liveItem.roomid);
return Card(
elevation: 0,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(0),
side: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.08),
width: 1,
),
),
margin: EdgeInsets.zero,
child: InkWell(
onTap: () {},
child: Column(
children: [
AspectRatio(
aspectRatio: StyleString.aspectRatio,
child: LayoutBuilder(builder: (context, boxConstraints) {
double maxWidth = boxConstraints.maxWidth;
double maxHeight = boxConstraints.maxHeight;
double PR = MediaQuery.of(context).devicePixelRatio;
return Stack(
children: [
Hero(
tag: heroTag,
child: NetworkImgLayer(
// 指定图片尺寸
// src: videoItem.pic + '@${(maxWidth * 2).toInt()}w',
src: liveItem.cover + '@.webp',
type: 'emote',
width: maxWidth,
height: maxHeight,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedOpacity(
opacity: 1,
duration: const Duration(milliseconds: 200),
child: LiveStat(
// view: liveItem.stat.view,
// danmaku: liveItem.stat.danmaku,
// duration: liveItem.duration,
online: liveItem.online,
),
),
),
],
);
}),
),
LiveContent(liveItem: liveItem)
],
),
),
);
}
}
class LiveContent extends StatelessWidget {
final liveItem;
const LiveContent({Key? key, required this.liveItem}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
// 多列
padding: const EdgeInsets.fromLTRB(8, 8, 6, 7),
// 单列
// padding: const EdgeInsets.fromLTRB(14, 10, 4, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
liveItem.title,
textAlign: TextAlign.start,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
maxLines: Get.find<HomeController>().crossAxisCount,
overflow: TextOverflow.ellipsis,
),
SizedBox(
width: double.infinity,
child: Text(
liveItem.uname,
maxLines: 1,
style: TextStyle(
fontSize: Theme.of(context).textTheme.labelMedium!.fontSize,
color: Theme.of(context).colorScheme.outline,
),
),
),
],
),
);
}
}
class LiveStat extends StatelessWidget {
final int? online;
const LiveStat({Key? key, required this.online}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 45,
padding: const EdgeInsets.only(top: 22, left: 8, right: 8),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.transparent,
Colors.black54,
],
tileMode: TileMode.mirror,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
// StatView(
// theme: 'white',
// view: view,
// ),
// const SizedBox(width: 8),
// StatDanMu(
// theme: 'white',
// danmu: danmaku,
// ),
],
),
Text(
online.toString(),
style: const TextStyle(fontSize: 11, color: Colors.white),
)
],
),
);
}
}

View File

@ -36,7 +36,7 @@ class NetworkImgLayer extends StatelessWidget {
? 0
: StyleString.imgRadius.x),
child: CachedNetworkImage(
imageUrl: src!,
imageUrl: src!.startsWith('//') ? 'https:${src!}' : src!,
width: width ?? double.infinity,
height: height ?? double.infinity,
alignment: Alignment.center,

View File

@ -147,4 +147,7 @@ class Api {
// 搜索关键词
static const String serachSuggest =
'https://s.search.bilibili.com/main/suggest';
// 分类搜索
static const String searchByType = '/x/web-interface/search/type';
}

View File

@ -1,5 +1,9 @@
import 'dart:developer';
import 'package:pilipala/http/index.dart';
import 'package:pilipala/models/common/search_type.dart';
import 'package:pilipala/models/search/hot.dart';
import 'package:pilipala/models/search/result.dart';
import 'package:pilipala/models/search/suggest.dart';
class SearchHttp {
@ -37,4 +41,45 @@ class SearchHttp {
};
}
}
// 分类搜索
static Future searchByType({
required SearchType searchType,
required String keyword,
required page,
}) async {
var res = await Request().get(Api.searchByType, data: {
'search_type': searchType.type,
'keyword': keyword,
'order_sort': 0,
'user_type': 0,
'page': page
});
if (res.data['code'] == 0) {
var data;
// log(res.data.toString());
switch (searchType) {
case SearchType.video:
data = SearchVideoModel.fromJson(res.data['data']);
break;
case SearchType.live_room:
data = SearchLiveModel.fromJson(res.data['data']);
break;
case SearchType.bili_user:
data = SearchUserModel.fromJson(res.data['data']);
break;
}
return {
'status': true,
'data': data,
};
} else {
return {
'status': false,
'date': [],
'msg': '请求错误 🙅',
};
}
}
}

View File

@ -0,0 +1,28 @@
// ignore_for_file: constant_identifier_names
enum SearchType {
// 视频video
video,
// 番剧media_bangumi,
// media_bangumi,
// 影视media_ft
// media_ft,
// 直播间及主播live
// live,
// 直播间live_room
live_room,
// 主播live_user
// live_user,
// 专栏article
// article,
// 话题topic
// topic,
// 用户bili_user
bili_user,
// 相簿photo
// photo
}
extension SearchTypeExtension on SearchType {
String get type => ['video', 'live_room', 'bili_user'][index];
String get label => ['视频', '直播间', '用户'][index];
}

View File

@ -0,0 +1,265 @@
class SearchVideoModel {
SearchVideoModel({this.list});
List<SearchVideoItemModel>? list;
SearchVideoModel.fromJson(Map<String, dynamic> json) {
list = json['result']
.map<SearchVideoItemModel>((e) => SearchVideoItemModel.fromJson(e))
.toList();
}
}
class SearchVideoItemModel {
SearchVideoItemModel({
this.type,
this.id,
this.cid,
// this.author,
// this.mid,
// this.typeid,
// this.typename,
this.arcurl,
this.aid,
this.bvid,
this.title,
this.description,
this.pic,
// this.play,
this.videoReview,
// this.favorites,
this.tag,
// this.review,
this.pubdate,
this.senddate,
this.duration,
// this.viewType,
// this.like,
// this.upic,
// this.danmaku,
this.owner,
this.stat,
this.rcmdReason,
});
String? type;
int? id;
int? cid;
// String? author;
// String? mid;
// String? typeid;
// String? typename;
String? arcurl;
int? aid;
String? bvid;
String? title;
String? description;
String? pic;
// String? play;
int? videoReview;
// String? favorites;
String? tag;
// String? review;
int? pubdate;
int? senddate;
String? duration;
// String? viewType;
// String? like;
// String? upic;
// String? danmaku;
Owner? owner;
Stat? stat;
String? rcmdReason;
SearchVideoItemModel.fromJson(Map<String, dynamic> json) {
type = json['type'];
id = json['id'];
cid = json['id'];
arcurl = json['arcurl'];
aid = json['aid'];
title = json['title'].replaceAll(RegExp(r'<.*?>'), '');
description = json['description'];
pic = 'https:${json['pic']}';
videoReview = json['video_review'];
pubdate = json['pubdate'];
senddate = json['senddate'];
duration = json['duration'];
owner = Owner.fromJson(json);
stat = Stat.fromJson(json);
}
}
class Stat {
Stat({
this.view,
this.danmaku,
this.favorite,
this.reply,
this.like,
});
// 播放量
int? view;
// 弹幕数
int? danmaku;
// 收藏数
int? favorite;
// 评论数
int? reply;
// 喜欢
int? like;
Stat.fromJson(Map<String, dynamic> json) {
view = json['play'];
danmaku = json['danmaku'];
favorite = json['favorite'];
reply = json['review'];
like = json['like'];
}
}
class Owner {
Owner({
this.mid,
this.name,
this.face,
});
int? mid;
String? name;
String? face;
Owner.fromJson(Map<String, dynamic> json) {
mid = json["mid"];
name = json["author"];
face = json['upic'];
}
}
class SearchUserModel {
SearchUserModel({this.list});
List<SearchUserItemModel>? list;
SearchUserModel.fromJson(Map<String, dynamic> json) {
list = json['result']
.map<SearchUserItemModel>((e) => SearchUserItemModel.fromJson(e))
.toList();
}
}
class SearchUserItemModel {
SearchUserItemModel({
this.type,
this.mid,
this.uname,
this.usign,
this.fans,
this.videos,
this.upic,
this.faceNft,
this.faceNftType,
this.verifyInfo,
this.level,
this.gender,
this.isUpUser,
this.isLive,
this.roomId,
this.officialVerify,
});
String? type;
int? mid;
String? uname;
String? usign;
int? fans;
int? videos;
String? upic;
int? faceNft;
int? faceNftType;
String? verifyInfo;
int? level;
int? gender;
int? isUpUser;
int? isLive;
int? roomId;
Map? officialVerify;
SearchUserItemModel.fromJson(Map<String, dynamic> json) {
type = json['type'];
mid = json['mid'];
uname = json['uname'];
usign = json['usign'];
fans = json['fans'];
videos = json['videos'];
upic = json['upic'];
faceNft = json['face_nft'];
faceNftType = json['face_nft_type'];
verifyInfo = json['verify_info'];
level = json['level'];
gender = json['gender'];
isUpUser = json['is_upuser'];
isLive = json['is_live'];
roomId = json['room_id'];
officialVerify = json['official_verify'];
}
}
class SearchLiveModel {
SearchLiveModel({this.list});
List<SearchLiveItemModel>? list;
SearchLiveModel.fromJson(Map<String, dynamic> json) {
list = json['result']
.map<SearchLiveItemModel>((e) => SearchLiveItemModel.fromJson(e))
.toList();
}
}
class SearchLiveItemModel {
SearchLiveItemModel({
this.rankOffset,
this.uid,
this.tags,
this.liveTime,
this.uname,
this.uface,
this.userCover,
this.type,
this.title,
this.cover,
this.online,
this.rankIndex,
this.rankScore,
this.roomid,
this.attentions,
});
int? rankOffset;
int? uid;
String? tags;
String? liveTime;
String? uname;
String? uface;
String? userCover;
String? type;
String? title;
String? cover;
int? online;
int? rankIndex;
int? rankScore;
int? roomid;
int? attentions;
SearchLiveItemModel.fromJson(Map<String, dynamic> json) {
rankOffset = json['rank_offset'];
uid = json['uid'];
tags = json['tags'];
liveTime = json['live_time'];
uname = json['uname'];
uface = json['uface'];
userCover = json['user_cover'];
type = json['type'];
title = json['title'];
cover = json['cover'];
online = json['online'];
rankIndex = json['rank_index'];
rankScore = json['rank_score'];
roomid = json['roomid'];
attentions = json['attentions'];
}
}

View File

@ -34,7 +34,6 @@ class SearchSuggestItem {
String reg = '<em class=\"suggest_high_light\">$inputTerm</em>';
try {
if (json['name'].indexOf(inputTerm) != -1) {
print(json['name']);
String str = json['name'].replaceAll(reg, '^');
List arr = str.split('^');
arr.insert(arr.length - 1, inputTerm);

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/search.dart';
import 'package:pilipala/models/common/search_type.dart';
class SearchPanelController extends GetxController {
SearchPanelController({this.keyword, this.searchType});
ScrollController scrollController = ScrollController();
String? keyword;
SearchType? searchType;
RxInt page = 1.obs;
RxList resultList = [].obs;
@override
void onInit() {
super.onInit();
}
Future onSearch({type = 'init'}) async {
var result = await SearchHttp.searchByType(
searchType: searchType!, keyword: keyword!, page: page.value);
if (result['status']) {
if (type == 'init') {
page.value++;
resultList.addAll(result['data'].list);
} else {
resultList.value = result['data'].list;
}
}
return result;
}
Future onRefresh() async {
page.value = 1;
onSearch(type: 'refresh');
}
// 返回顶部并刷新
void animateToTop() async {
if (scrollController.offset >=
MediaQuery.of(Get.context!).size.height * 5) {
scrollController.jumpTo(0);
} else {
await scrollController.animateTo(0,
duration: const Duration(milliseconds: 500), curve: Curves.easeInOut);
}
}
}

View File

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

View File

@ -0,0 +1,94 @@
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/live_card.dart';
import 'package:pilipala/common/widgets/video_card_h.dart';
import 'package:pilipala/models/common/search_type.dart';
import 'controller.dart';
import 'widgets/userPanel.dart';
class SearchPanel extends StatefulWidget {
String? keyword;
SearchType? searchType;
SearchPanel({required this.keyword, required this.searchType, Key? key})
: super(key: key);
@override
State<SearchPanel> createState() => _SearchPanelState();
}
class _SearchPanelState extends State<SearchPanel>
with AutomaticKeepAliveClientMixin {
late SearchPanelController? _searchPanelController;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_searchPanelController = Get.put(
SearchPanelController(
keyword: widget.keyword,
searchType: widget.searchType,
),
tag: widget.searchType!.type);
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async {
await _searchPanelController!.onRefresh();
},
child: FutureBuilder(
future: _searchPanelController!.onSearch(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data;
if (data['status']) {
return Obx(
() => ListView.builder(
controller: _searchPanelController!.scrollController,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: _searchPanelController!.resultList.length,
itemBuilder: (context, index) {
var i = _searchPanelController!.resultList[index];
switch (widget.searchType) {
case SearchType.video:
return VideoCardH(videoItem: i);
case SearchType.bili_user:
return UserPanel(userItem: i);
case SearchType.live_room:
return LiveCard(liveItem: i);
default:
return const SizedBox();
}
},
),
);
} else {
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return ListView.builder(
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: 15,
itemBuilder: (context, index) {
return const VideoCardHSkeleton();
},
);
}
},
),
);
}
}

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
class UserPanel extends StatelessWidget {
var userItem;
UserPanel({super.key, this.userItem});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
child: Row(
children: [
NetworkImgLayer(
width: 42,
height: 42,
src: userItem.upic,
type: 'avatar',
),
const SizedBox(width: 10),
Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Text(
userItem!.uname,
style: TextStyle(
// color: replyItem!.isUp! ||
// replyItem!.member!.vip!['vipType'] > 0
// ? Theme.of(context).colorScheme.primary
// : Theme.of(context).colorScheme.outline,
fontSize:
Theme.of(context).textTheme.titleMedium!.fontSize,
),
),
const SizedBox(width: 6),
Image.asset(
'assets/images/lv/lv${userItem!.level}.png',
height: 11,
),
],
),
if (userItem.officialVerify['desc'] != '')
Text(
userItem.officialVerify['desc'],
style: TextStyle(
fontSize:
Theme.of(context).textTheme.labelSmall!.fontSize,
color: Theme.of(context).colorScheme.outline),
),
],
)
],
),
),
);
}
}

View File

@ -1,15 +1,10 @@
import 'package:get/get.dart';
import 'package:pilipala/models/common/search_type.dart';
import 'package:pilipala/pages/searchPanel/index.dart';
class SearchResultController extends GetxController {
String? keyword;
List tabs = [
{'label': '综合', 'id': ''},
{'label': '视频', 'id': ''},
{'label': '番剧', 'id': ''},
{'label': '直播', 'id': ''},
{'label': '专栏', 'id': ''},
{'label': '用户', 'id': ''}
];
int tabIndex = 0;
@override
void onInit() {

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/models/common/search_type.dart';
import 'package:pilipala/pages/searchPanel/index.dart';
import 'controller.dart';
class SearchResultPage extends StatefulWidget {
@ -9,14 +11,32 @@ class SearchResultPage extends StatefulWidget {
State<SearchResultPage> createState() => _SearchResultPageState();
}
class _SearchResultPageState extends State<SearchResultPage> {
class _SearchResultPageState extends State<SearchResultPage>
with TickerProviderStateMixin {
final SearchResultController _searchResultController =
Get.put(SearchResultController());
late TabController? _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(
vsync: this,
length: SearchType.values.length,
initialIndex: _searchResultController.tabIndex,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
shape: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.08),
width: 1,
),
),
titleSpacing: 0,
centerTitle: false,
title: GestureDetector(
@ -30,9 +50,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
),
),
),
body: DefaultTabController(
length: _searchResultController.tabs.length,
child: Column(
body: Column(
children: [
Theme(
data: ThemeData(
@ -40,9 +58,10 @@ class _SearchResultPageState extends State<SearchResultPage> {
highlightColor: Colors.transparent, // 点击时的背景高亮颜色设置为透明
),
child: TabBar(
tabs: _searchResultController.tabs
.map((e) => Tab(text: e['label']))
.toList(),
controller: _tabController,
tabs: [
for (var i in SearchType.values) Tab(text: i.label),
],
isScrollable: true,
indicatorWeight: 0,
indicatorPadding:
@ -59,29 +78,31 @@ class _SearchResultPageState extends State<SearchResultPage> {
dividerColor: Colors.transparent,
unselectedLabelColor: Theme.of(context).colorScheme.outline,
onTap: (index) {
print(index);
if (index == _searchResultController.tabIndex) {
Get.find<SearchPanelController>(
tag: SearchType.values[index].type)
.animateToTop();
}
_searchResultController.tabIndex = index;
},
),
),
const SizedBox(height: 4),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
Container(
width: 200,
height: 200,
color: Colors.amber,
),
Text('1'),
Text('1'),
Text('1'),
Text('1'),
Text('1'),
for (var i in SearchType.values) ...{
SearchPanel(
keyword: _searchResultController.keyword,
searchType: i,
)
}
],
),
),
],
),
),
);
}
}

10
lib/pages/video/README.md Normal file
View File

@ -0,0 +1,10 @@
视频详情页预渲染
+ videoItem
+ title
+ stat
+ view
+ danmaku
+ pubdate
+ owner
+ face
+ name

View File

@ -26,8 +26,11 @@ class Utils {
}
}
static String timeFormat(int time) {
static String timeFormat(dynamic time) {
// 1小时内
if (time is String && time.contains(':')) {
return time;
}
if (time < 3600) {
int minute = time ~/ 60;
double res = time / 60;