mod: merge main

This commit is contained in:
guozhigq
2023-05-15 09:52:09 +08:00
141 changed files with 7011 additions and 363 deletions

View File

@ -0,0 +1,119 @@
import 'package:pilipala/common/constants.dart';
import 'package:flutter/material.dart';
import 'skeleton.dart';
class VideoCardHSkeleton extends StatefulWidget {
const VideoCardHSkeleton({super.key});
@override
State<VideoCardHSkeleton> createState() => _VideoCardHSkeletonState();
}
class _VideoCardHSkeletonState extends State<VideoCardHSkeleton> {
@override
Widget build(BuildContext context) {
return Skeleton(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
StyleString.cardSpace, 7, StyleString.cardSpace, 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;
double PR = MediaQuery.of(context).devicePixelRatio;
return Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
borderRadius: BorderRadius.circular(
StyleString.imgRadius.x),
),
);
},
),
),
// VideoContent(videoItem: videoItem)
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 6, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
width: 200,
height: 11,
margin: const EdgeInsets.only(bottom: 5),
),
Container(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
width: 150,
height: 13,
),
const Spacer(),
Container(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
width: 100,
height: 13,
margin: const EdgeInsets.only(bottom: 5),
),
Row(
children: [
Container(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
width: 40,
height: 13,
margin: const EdgeInsets.only(right: 8),
),
Container(
color: Theme.of(context)
.colorScheme
.onInverseSurface,
width: 40,
height: 13,
),
],
)
],
),
)),
],
),
);
},
),
),
Divider(
height: 1,
indent: 8,
endIndent: 12,
color: Theme.of(context).dividerColor.withOpacity(0.08),
)
],
),
);
}
}

View File

@ -23,14 +23,7 @@ class VideoCardVSkeleton extends StatelessWidget {
return Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withOpacity(0.1),
),
),
);
},
@ -49,15 +42,15 @@ class VideoCardVSkeleton extends StatelessWidget {
Container(
width: 200,
height: 13,
margin: const EdgeInsets.only(bottom: 5),
color: Theme.of(context).colorScheme.background,
),
const SizedBox(height: 5),
Container(
width: 150,
height: 13,
margin: const EdgeInsets.only(bottom: 12),
color: Theme.of(context).colorScheme.background,
),
const SizedBox(height: 12),
Container(
width: 80,
height: 13,

View File

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'skeleton.dart';
class VideoReplySkeleton extends StatelessWidget {
const VideoReplySkeleton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
Color bgColor = Theme.of(context).colorScheme.onInverseSurface;
return Skeleton(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 2),
child: Row(
children: [
ClipOval(
child: Container(
width: 34,
height: 34,
color: bgColor,
),
),
const SizedBox(width: 12),
Container(
width: 80,
height: 13,
color: bgColor,
)
],
),
),
Container(
width: double.infinity,
margin:
const EdgeInsets.only(top: 4, left: 57, right: 6, bottom: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
width: 300,
height: 14,
margin: const EdgeInsets.only(bottom: 4),
color: bgColor,
),
Container(
width: 180,
height: 14,
margin: const EdgeInsets.only(bottom: 10),
color: bgColor,
),
Row(
children: [
Container(
width: 40,
height: 14,
margin: const EdgeInsets.only(bottom: 4),
color: bgColor,
),
const Spacer(),
Container(
width: 40,
height: 14,
margin: const EdgeInsets.only(bottom: 4),
color: bgColor,
),
const SizedBox(width: 8)
],
)
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class AppBarWidget extends StatelessWidget implements PreferredSizeWidget {
const AppBarWidget({
required this.child,
required this.controller,
required this.visible,
Key? key,
}) : super(key: key);
final PreferredSizeWidget child;
final AnimationController controller;
final bool visible;
@override
// TODO: implement preferredSize
Size get preferredSize => child.preferredSize;
@override
Widget build(BuildContext context) {
visible ? controller.reverse() : controller.forward();
return SlideTransition(
position: Tween<Offset>(
begin: Offset.zero,
end: const Offset(0, -1),
).animate(CurvedAnimation(
parent: controller,
curve: Curves.easeInOutBack,
)),
child: child,
);
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class HttpError extends StatelessWidget {
HttpError({required this.errMsg, required this.fn, super.key});
String errMsg = '';
final Function()? fn;
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(
height: 150,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
errMsg,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () {
fn!();
},
child: const Text('点击重试'))
],
),
),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:pilipala/common/constants.dart';
class NetworkImgLayer extends StatelessWidget {
final String? src;
@ -29,11 +30,16 @@ class NetworkImgLayer extends StatelessWidget {
// double pr = 2;
return src != ''
? ClipRRect(
borderRadius: BorderRadius.circular(type == 'avatar' ? 50 : 4),
borderRadius: BorderRadius.circular(type == 'avatar'
? 50
: type == 'emote'
? 0
: StyleString.imgRadius.x),
child: CachedNetworkImage(
imageUrl: src!,
width: width ?? double.infinity,
height: height ?? double.infinity,
alignment: Alignment.center,
maxWidthDiskCache: ((cacheW ?? width!) * pr).toInt(),
// maxHeightDiskCache: (cacheH ?? height!).toInt(),
memCacheWidth: ((cacheW ?? width!) * pr).toInt(),

View File

@ -1,3 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/utils/utils.dart';
@ -11,21 +12,21 @@ class StatDanMu extends StatelessWidget {
@override
Widget build(BuildContext context) {
Color color =
theme == 'white' ? Colors.white : Theme.of(context).colorScheme.outline;
return Row(
children: [
Image.asset(
'assets/images/dm_$theme.png',
width: size == 'medium' ? 16 : 14,
height: size == 'medium' ? 16 : 14,
Icon(
CupertinoIcons.ellipses_bubble,
size: 14,
color: color,
),
const SizedBox(width: 2),
const SizedBox(width: 3),
Text(
Utils.numFormat(danmu!),
style: TextStyle(
fontSize: size == 'medium' ? 12 : 11,
color: theme == 'white'
? Colors.white
: Theme.of(context).colorScheme.outline,
color: color,
),
)
],

View File

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class UpTag extends StatelessWidget {
const UpTag({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: 14,
height: 10,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
border: Border.all(color: Theme.of(context).colorScheme.outline)),
margin: const EdgeInsets.only(right: 4),
child: Center(
child: Text(
'UP',
style: TextStyle(
fontSize: 6, color: Theme.of(context).colorScheme.outline),
),
),
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/utils/utils.dart';
@ -6,26 +7,26 @@ class StatView extends StatelessWidget {
final int? view;
final String? size;
const StatView({Key? key, this.theme, this.view, this.size}) : super(key: key);
const StatView({Key? key, this.theme, this.view, this.size})
: super(key: key);
@override
Widget build(BuildContext context) {
Color color =
theme == 'white' ? Colors.white : Theme.of(context).colorScheme.outline;
return Row(
children: [
Image.asset(
'assets/images/view_$theme.png',
width: size == 'medium' ? 16 : 14,
height: size == 'medium' ? 16 : 14,
Icon(
CupertinoIcons.play_rectangle,
size: 13,
color: color,
),
const SizedBox(width: 2),
const SizedBox(width: 3),
Text(
Utils.numFormat(view!),
// videoItem['stat']['view'].toString(),
style: TextStyle(
fontSize: size == 'medium' ? 12 : 11,
color: theme == 'white'
? Colors.white
: Theme.of(context).colorScheme.outline,
color: color,
),
),
],

View File

@ -7,6 +7,7 @@ import 'package:pilipala/common/widgets/network_img_layer.dart';
// 视频卡片 - 水平布局
class VideoCardH extends StatelessWidget {
// ignore: prefer_typing_uninitialized_variables
var videoItem;
Function()? longPress;
Function()? longPressEnd;
@ -20,84 +21,94 @@ class VideoCardH extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
child: Ink(
child: GestureDetector(
onLongPress: () {
longPress!();
},
onLongPressEnd: (details) {
longPressEnd!();
},
child: InkWell(
onTap: () async {
await Future.delayed(const Duration(milliseconds: 200));
int aid = videoItem.aid ?? videoItem.id;
Get.toNamed('/video?aid=$aid',
arguments: {'videoItem': videoItem});
},
child: Container(
int aid = videoItem.aid;
String heroTag = Utils.makeHeroTag(aid);
return GestureDetector(
onLongPress: () {
longPress!();
},
onLongPressEnd: (details) {
longPressEnd!();
},
child: InkWell(
onTap: () async {
await Future.delayed(const Duration(milliseconds: 200));
Get.toNamed('/video?aid=$aid',
arguments: {'videoItem': videoItem, 'heroTag': heroTag});
},
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
StyleString.cardSpace, 5, StyleString.cardSpace, 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: ClipRRect(
// borderRadius: StyleString.mdRadius,
child: LayoutBuilder(
builder: (context, boxConstraints) {
double maxWidth = boxConstraints.maxWidth;
double maxHeight = boxConstraints.maxHeight;
double PR = MediaQuery.of(context).devicePixelRatio;
return Stack(
children: [
NetworkImgLayer(
// src: videoItem['pic'] +
// '@${(maxWidth * 2).toInt()}w',
src: videoItem.pic + '@.webp',
width: maxWidth,
height: maxHeight,
),
// Image.network( videoItem['pic'], width: double.infinity, height: double.infinity,),
Positioned(
right: 4,
bottom: 4,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 1, horizontal: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Colors.black54.withOpacity(0.4)),
child: Text(
Utils.timeFormat(videoItem.duration!),
style: const TextStyle(
fontSize: 11, color: Colors.white),
StyleString.cardSpace, 7, StyleString.cardSpace, 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;
double PR =
MediaQuery.of(context).devicePixelRatio;
return Stack(
children: [
Hero(
tag: heroTag,
child: NetworkImgLayer(
// src: videoItem['pic'] +
// '@${(maxWidth * 2).toInt()}w',
src: videoItem.pic + '@.webp',
width: maxWidth,
height: maxHeight,
),
),
// Image.network( videoItem['pic'], width: double.infinity, height: double.infinity,),
)
],
);
},
Positioned(
right: 4,
bottom: 4,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 1, horizontal: 6),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(4),
color:
Colors.black54.withOpacity(0.4)),
child: Text(
Utils.timeFormat(videoItem.duration!),
style: const TextStyle(
fontSize: 11, color: Colors.white),
),
),
)
],
);
},
),
),
// ),
),
VideoContent(videoItem: videoItem)
],
),
);
}),
// height: 124,
VideoContent(videoItem: videoItem)
],
),
);
},
),
),
),
Divider(
height: 1,
indent: 8,
endIndent: 12,
color: Theme.of(context).dividerColor.withOpacity(0.08),
)
],
),
),
);
@ -126,7 +137,7 @@ class VideoContent extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
const Spacer(),
if (videoItem.rcmdReason != '' &&
if (videoItem.rcmdReason != null &&
videoItem.rcmdReason.content != '')
Container(
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 5),
@ -145,12 +156,6 @@ class VideoContent extends StatelessWidget {
const SizedBox(height: 4),
Row(
children: [
Image.asset(
'assets/images/up_gray.png',
width: 14,
height: 12,
),
const SizedBox(width: 2),
Text(
videoItem.owner.name,
style: TextStyle(

View File

@ -22,6 +22,7 @@ class VideoCardV extends StatelessWidget {
@override
Widget build(BuildContext context) {
String heroTag = Utils.makeHeroTag(videoItem.id);
return Card(
elevation: 0.8,
clipBehavior: Clip.hardEdge,
@ -40,7 +41,7 @@ class VideoCardV extends StatelessWidget {
onTap: () async {
await Future.delayed(const Duration(milliseconds: 200));
Get.toNamed('/video?aid=${videoItem.id}',
arguments: {'videoItem': videoItem});
arguments: {'videoItem': videoItem, 'heroTag': heroTag});
},
child: Column(
children: [
@ -57,12 +58,15 @@ class VideoCardV extends StatelessWidget {
double PR = MediaQuery.of(context).devicePixelRatio;
return Stack(
children: [
NetworkImgLayer(
// 指定图片尺寸
// src: videoItem.pic + '@${(maxWidth * 2).toInt()}w',
src: videoItem.pic + '@.webp',
width: maxWidth,
height: maxHeight,
Hero(
tag: heroTag,
child: NetworkImgLayer(
// 指定图片尺寸
// src: videoItem.pic + '@${(maxWidth * 2).toInt()}w',
src: videoItem.pic + '@.webp',
width: maxWidth,
height: maxHeight,
),
),
Positioned(
left: 0,
@ -77,7 +81,7 @@ class VideoCardV extends StatelessWidget {
duration: videoItem.duration,
),
),
)
),
],
);
}),
@ -141,6 +145,25 @@ class VideoContent extends StatelessWidget {
),
),
const SizedBox(width: 4)
] else if (videoItem.isFollowed == 1) ...[
Container(
padding: const EdgeInsets.fromLTRB(3, 1, 3, 1),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.6),
borderRadius: BorderRadius.circular(3)),
child: Text(
'已关注',
style: TextStyle(
fontSize:
Theme.of(context).textTheme.labelSmall!.fontSize,
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 4)
],
Expanded(
child: LayoutBuilder(builder:

View File

@ -1,9 +1,124 @@
class Api {
// 推荐视频
static const String recommendList = '/x/web-interface/index/top/feed/rcmd';
static const String recommendList = '/x/web-interface/index/top/rcmd';
// 热门视频
static const String hotList = '/x/web-interface/popular';
// 视频详情
// 竖屏 https://api.bilibili.com/x/web-interface/view?aid=527403921
static const String videoDetail = '/x/web-interface/view';
// https://api.bilibili.com/x/web-interface/view/detail 获取视频超详细信息(web端)
static const String videoIntro = '/x/web-interface/view';
// 视频详情 超详细
// https://api.bilibili.com/x/web-interface/view/detail?aid=527403921
/// https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/video/action.md
// 点赞 Post
/// aid num 稿件avid 必要(可选) avid与bvid任选一个
/// bvid str 稿件bvid 必要(可选) avid与bvid任选一个
/// like num 操作方式 必要 1点赞 2取消赞
// csrf str CSRF Token位于cookie 必要
// https://api.bilibili.com/x/web-interface/archive/like
static const String likeVideo = '/x/web-interface/archive/like';
//判断视频是否被点赞双端Get
// access_key str APP登录Token APP方式必要
/// aid num 稿件avid 必要(可选) avid与bvid任选一个
/// bvid str 稿件bvid 必要(可选) avid与bvid任选一个
// https://api.bilibili.com/x/web-interface/archive/has/like
static const String hasLikeVideo = '/x/web-interface/archive/has/like';
// 视频点踩 web端不支持
// 投币视频web端POST
/// aid num 稿件avid 必要(可选) avid与bvid任选一个
/// bvid str 稿件bvid 必要(可选) avid与bvid任选一个
/// multiply num 投币数量 必要 上限为2
/// select_like num 是否附加点赞 非必要 0不点赞 1同时点赞 默认为0
// csrf str CSRF Token位于cookie 必要
// https://api.bilibili.com/x/web-interface/coin/add
static const String coinVideo = '/x/web-interface/coin/add';
// 判断视频是否被投币双端GET
// access_key str APP登录Token APP方式必要
/// aid num 稿件avid 必要(可选) avid与bvid任选一个
/// bvid str 稿件bvid 必要(可选) avid与bvid任选一个
/// https://api.bilibili.com/x/web-interface/archive/coins
static const String hasCoinVideo = '/x/web-interface/archive/coins';
// 收藏视频双端POST
// access_key str APP登录Token APP方式必要
/// rid num 稿件avid 必要
/// type num 必须为2 必要
/// add_media_ids nums 需要加入的收藏夹mlid 非必要 同时添加多个,用,%2C分隔
/// del_media_ids nums 需要取消的收藏夹mlid 非必要 同时取消多个,用,%2C分隔
// csrf str CSRF Token位于cookie Cookie方式必要
// https://api.bilibili.com/medialist/gateway/coll/resource/deal
// https://api.bilibili.com/x/v3/fav/resource/deal
static const String favVideo = '/x/v3/fav/resource/deal';
// 判断视频是否被收藏双端GET
/// aid
// https://api.bilibili.com/x/v2/fav/video/favoured
static const String hasFavVideo = '/x/v2/fav/video/favoured';
// 分享视频 Web端 POST
// https://api.bilibili.com/x/web-interface/share/add
// aid num 稿件avid 必要(可选) avid与bvid任选一个
// bvid str 稿件bvid 必要(可选) avid与bvid任选一个
// csrf str CSRF Token位于cookie 必要
// 一键三连
// https://api.bilibili.com/x/web-interface/archive/like/triple
// aid num 稿件avid 必要(可选) avid与bvid任选一个
// bvid str 稿件bvid 必要(可选) avid与bvid任选一个
// csrf str CSRF Token位于cookie 必要
static const String oneThree = '/x/web-interface/archive/like/triple';
// 获取指定用户创建的所有收藏夹信息
// 该接口也能查询目标内容id存在于那些收藏夹中
// up_mid num 目标用户mid 必要
// type num 目标内容属性 非必要 默认为全部 0全部 2视频稿件
// rid num 目标 视频稿件avid
static const String videoInFolder = '/x/v3/fav/folder/created/list-all';
// 视频详情页 相关视频
static const String relatedList = '/x/web-interface/archive/related';
// 评论列表
static const String replyList = '/x/v2/reply';
// 楼中楼
static const String replyReplyList = '/x/v2/reply/reply';
// 发表评论
// https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/comment/action.md
static const String replyAdd = '/x/v2/reply/add';
// 用户(被)关注数、投稿数
// https://api.bilibili.com/x/relation/stat?vmid=697166795
static const String userStat = '/x/relation/stat';
// 获取用户信息
static const String userInfo = '/x/web-interface/nav';
// 获取当前用户状态
static const String userStatOwner = '/x/web-interface/nav/stat';
// 收藏夹
// https://api.bilibili.com/x/v3/fav/folder/created/list?pn=1&ps=10&up_mid=17340771
static const String userFavFolder = '/x/v3/fav/folder/created/list';
/// 收藏夹 详情
/// media_id int 收藏夹id
/// pn int 当前页
/// ps int pageSize
/// keyword String 搜索词
/// order String 排序方式 view 最多播放 mtime 最近收藏 pubtime 最近投稿
/// tid int 分区id
// https://api.bilibili.com/x/v3/fav/resource/list?media_id=76614671&pn=1&ps=20&keyword=&order=mtime&type=0&tid=0
static const String userFavFolderDetail = '/x/v3/fav/resource/list';
// 正在直播的up & 关注的up
// https://api.bilibili.com/x/polymer/web-dynamic/v1/portal
}

View File

@ -12,6 +12,7 @@ import 'package:dio_cookie_manager/dio_cookie_manager.dart';
class Request {
static final Request _instance = Request._internal();
static late CookieManager cookieManager;
factory Request() => _instance;
@ -31,11 +32,9 @@ class Request {
ignoreExpires: true,
storage: FileStorage(cookiePath),
);
dio.interceptors.add(CookieManager(cookieJar));
var cookie = await CookieManager(cookieJar)
.cookieJar
cookieManager = CookieManager(cookieJar);
dio.interceptors.add(cookieManager);
var cookie = await cookieManager.cookieJar
.loadForRequest(Uri.parse(HttpString.baseUrl));
if (cookie.isEmpty) {
try {
@ -46,6 +45,27 @@ class Request {
}
}
// 移除cookie
static removeCookie() async {
await cookieManager.cookieJar
.saveFromResponse(Uri.parse(HttpString.baseUrl), []);
await cookieManager.cookieJar
.saveFromResponse(Uri.parse(HttpString.baseApiUrl), []);
cookieManager.cookieJar.deleteAll();
dio.interceptors.add(cookieManager);
}
// 从cookie中获取 csrf token
static Future<String> getCsrf() async {
var cookies = await cookieManager.cookieJar
.loadForRequest(Uri.parse(HttpString.baseApiUrl));
// for (var i in cookies) {
// print(i);
// }
var token = cookies.firstWhere((e) => e.name == 'bili_jct').value;
return token;
}
/*
* config it and create
*/
@ -111,20 +131,21 @@ class Request {
return response;
} on DioError catch (e) {
print('get error: $e');
return Future.error(ApiInterceptor.dioError(e));
return Future.error(await ApiInterceptor.dioError(e));
}
}
/*
* post请求
*/
post(url, {data, options, cancelToken, extra}) async {
post(url, {data, queryParameters, options, cancelToken, extra}) async {
print('post-data: $data');
Response response;
try {
response = await dio.post(
url,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
@ -132,7 +153,7 @@ class Request {
return response;
} on DioError catch (e) {
print('post error: $e');
return Future.error(ApiInterceptor.dioError(e));
return Future.error(await ApiInterceptor.dioError(e));
}
}

View File

@ -1,10 +1,12 @@
import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart' hide Response;
class ApiInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// print("请求之前");
print("请求之前");
// 在请求之前添加头部或认证信息
// options.headers['Authorization'] = 'Bearer token';
// options.headers['Content-Type'] = 'application/json';
@ -13,15 +15,14 @@ class ApiInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// print("响应之前");
handler.next(response);
}
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
void onError(DioError err, ErrorInterceptorHandler handler) async {
// 处理网络请求错误
handler.next(err);
// handler.next(err);
SmartDialog.showToast(await dioError(err));
super.onError(err, handler);
}
@ -43,7 +44,7 @@ class ApiInterceptor extends Interceptor {
return "发送请求超时,请检查网络设置";
case DioErrorType.unknown:
var res = await checkConect();
return "$res 网络异常,请稍后重试!";
return res + " \n 网络异常,请稍后重试!";
default:
return "Dio异常";
}

70
lib/http/reply.dart Normal file
View File

@ -0,0 +1,70 @@
import 'package:pilipala/http/api.dart';
import 'package:pilipala/http/init.dart';
class ReplyHttp {
static Future replyList({
required String oid,
required int pageNum,
required int type,
int sort = 1,
}) async {
var res = await Request().get(Api.replyList, data: {
'oid': oid,
'pn': pageNum,
'type': type,
'sort': 1,
});
if (res.data['code'] == 0) {
return {
'status': true,
'data': res.data['data'],
};
} else {
Map errMap = {
-400: '请求错误',
-404: '无此项',
12002: '评论区已关闭',
12009: '评论主体的type不合法',
};
return {
'status': false,
'date': [],
'msg': errMap[res.data['code']] ?? '请求异常',
};
}
}
static Future replyReplyList({
required String oid,
required String root,
required int pageNum,
required int type,
int sort = 1,
}) async {
var res = await Request().get(Api.replyReplyList, data: {
'oid': oid,
'root': root,
'pn': pageNum,
'type': type,
'sort': 1,
});
if (res.data['code'] == 0) {
return {
'status': true,
'data': res.data['data'],
};
} else {
Map errMap = {
-400: '请求错误',
-404: '无此项',
12002: '评论区已关闭',
12009: '评论主体的type不合法',
};
return {
'status': false,
'date': [],
'msg': errMap[res.data['code']] ?? '请求异常',
};
}
}
}

79
lib/http/user.dart Normal file
View File

@ -0,0 +1,79 @@
import 'package:pilipala/http/api.dart';
import 'package:pilipala/http/init.dart';
import 'package:pilipala/models/user/fav_detail.dart';
import 'package:pilipala/models/user/fav_folder.dart';
import 'package:pilipala/models/user/info.dart';
import 'package:pilipala/models/user/stat.dart';
class UserHttp {
static Future<dynamic> userStat({required int mid}) async {
var res = await Request().get(Api.userStat, data: {'vmid': mid});
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false};
}
}
static Future<dynamic> userInfo() async {
var res = await Request().get(Api.userInfo);
if (res.data['code'] == 0) {
UserInfoData data = UserInfoData.fromJson(res.data['data']);
return {'status': true, 'data': data};
} else {
return {'status': false, 'msg': res.data['message']};
}
}
static Future<dynamic> userStatOwner() async {
var res = await Request().get(Api.userStatOwner);
if (res.data['code'] == 0) {
UserStat data = UserStat.fromJson(res.data['data']);
return {'status': true, 'data': data};
} else {
return {'status': false, 'data': [], 'msg': res.data['message']};
}
}
// 收藏夹
static Future<dynamic> userfavFolder({
required int pn,
required int ps,
required int mid,
}) async {
var res = await Request().get(Api.userFavFolder, data: {
'pn': pn,
'ps': ps,
'up_mid': mid,
});
if (res.data['code'] == 0) {
FavFolderData data = FavFolderData.fromJson(res.data['data']);
return {'status': true, 'data': data};
} else {
return {'status': false, 'data': [], 'msg': res.data['message']};
}
}
static Future<dynamic> userFavFolderDetail(
{required int mediaId,
required int pn,
required int ps,
String keyword = '',
String order = 'mtime'}) async {
var res = await Request().get(Api.userFavFolderDetail, data: {
'media_id': mediaId,
'pn': pn,
'ps': ps,
'keyword': keyword,
'order': order,
'type': 0,
'tid': 0
});
if (res.data['code'] == 0) {
FavDetailData data = FavDetailData.fromJson(res.data['data']);
return {'status': true, 'data': data};
} else {
return {'status': false, 'data': [], 'msg': res.data['message']};
}
}
}

227
lib/http/video.dart Normal file
View File

@ -0,0 +1,227 @@
import 'dart:developer';
import 'package:pilipala/http/api.dart';
import 'package:pilipala/http/init.dart';
import 'package:pilipala/models/common/reply_type.dart';
import 'package:pilipala/models/model_hot_video_item.dart';
import 'package:pilipala/models/model_rec_video_item.dart';
import 'package:pilipala/models/user/fav_folder.dart';
import 'package:pilipala/models/video_detail_res.dart';
/// res.data['code'] == 0 请求正常返回结果
/// res.data['data'] 为结果
/// 返回{'status': bool, 'data': List}
/// view层根据 status 判断渲染逻辑
class VideoHttp {
// 首页推荐视频
static Future rcmdVideoList({required int ps, required int freshIdx}) async {
try {
var res = await Request().get(
Api.recommendList,
data: {
'feed_version': 'V4',
'ps': ps,
'fresh_idx': freshIdx,
},
);
if (res.data['code'] == 0) {
List<RecVideoItemModel> list = [];
for (var i in res.data['data']['item']) {
list.add(RecVideoItemModel.fromJson(i));
}
return {'status': true, 'data': list};
} else {
return {'status': false, 'data': [], 'msg': ''};
}
} catch (err) {
return {'status': false, 'data': [], 'msg': err.toString()};
}
}
// 最热视频
static Future hotVideoList({required int pn, required int ps}) async {
try {
var res = await Request().get(
Api.hotList,
data: {'pn': pn, 'ps': ps},
);
if (res.data['code'] == 0) {
List<HotVideoItemModel> list = [];
for (var i in res.data['data']['list']) {
list.add(HotVideoItemModel.fromJson(i));
}
return {'status': true, 'data': list};
} else {
return {'status': false, 'data': []};
}
} catch (err) {
return {'status': false, 'data': [], 'msg': err};
}
}
// 视频信息 标题、简介
static Future videoIntro({required String aid}) async {
var res = await Request().get(Api.videoIntro, data: {'aid': aid});
VideoDetailResponse result = VideoDetailResponse.fromJson(res.data);
if (result.code == 0) {
return {'status': true, 'data': result.data!};
} else {
Map errMap = {
-400: '请求错误',
-403: '权限不足',
-404: '无视频',
62002: '稿件不可见',
62004: '稿件审核中',
};
return {
'status': false,
'data': null,
'msg': errMap[result.code] ?? '请求异常',
};
}
}
// 相关视频
static Future relatedVideoList({required String aid}) async {
var res = await Request().get(Api.relatedList, data: {'aid': aid});
if (res.data['code'] == 0) {
List<HotVideoItemModel> list = [];
for (var i in res.data['data']) {
list.add(HotVideoItemModel.fromJson(i));
}
return {'status': true, 'data': list};
} else {
return {'status': false, 'data': []};
}
}
// 获取点赞状态
static Future hasLikeVideo({required String aid}) async {
var res = await Request().get(Api.hasLikeVideo, data: {'aid': aid});
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false, 'data': []};
}
}
// 获取投币状态
static Future hasCoinVideo({required String aid}) async {
var res = await Request().get(Api.hasCoinVideo, data: {'aid': aid});
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': true, 'data': []};
}
}
// 获取收藏状态
static Future hasFavVideo({required String aid}) async {
var res = await Request().get(Api.hasFavVideo, data: {'aid': aid});
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false, 'data': []};
}
}
// 一键三连
static Future oneThree({required String aid}) async {
var res = await Request().post(
Api.oneThree,
queryParameters: {
'aid': aid,
'csrf': await Request.getCsrf(),
},
);
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false, 'data': [], 'msg': res.data['message']};
}
}
// (取消)点赞
static Future likeVideo({required String aid, required bool type}) async {
var res = await Request().post(
Api.likeVideo,
queryParameters: {
'aid': aid,
'like': type ? 1 : 2,
'csrf': await Request.getCsrf(),
},
);
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false, 'data': [], 'msg': res.data['message']};
}
}
// (取消)收藏
static Future favVideo(
{required String aid, String? addIds, String? delIds}) async {
var res = await Request().post(Api.favVideo, queryParameters: {
'rid': aid,
'type': 2,
'add_media_ids': addIds ?? '',
'del_media_ids': delIds ?? '',
'csrf': await Request.getCsrf(),
});
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false, 'data': []};
}
}
// 查看视频被收藏在哪个文件夹
static Future videoInFolder({required int mid, required String rid}) async {
var res = await Request()
.get(Api.videoInFolder, data: {'up_mid': mid, 'rid': rid});
if (res.data['code'] == 0) {
FavFolderData data = FavFolderData.fromJson(res.data['data']);
return {'status': true, 'data': data};
} else {
return {'status': false, 'data': []};
}
}
// 发表评论 replyAdd
// type num 评论区类型代码 必要 类型代码见表
// oid num 目标评论区id 必要
// root num 根评论rpid 非必要 二级评论以上使用
// parent num 父评论rpid 非必要 二级评论同根评论id 大于二级评论为要回复的评论id
// message str 发送评论内容 必要 最大1000字符
// plat num 发送平台标识 非必要 1web端 2安卓客户端 3ios客户端 4wp客户端
static Future replyAdd({
required ReplyType type,
required int oid,
required String message,
int? root,
int? parent,
}) async {
if(message == ''){
return {'status': false, 'data': [], 'msg': '请输入评论内容'};
}
print('root:$root');
print('parent: $parent');
var res = await Request()
.post(Api.replyAdd, queryParameters: {
'type': type.index,
'oid': oid,
'root': root ?? '',
'parent': parent == null || parent == 0 ? '' : parent,
'message': message,
'csrf': await Request.getCsrf(),
});
log(res.toString());
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false, 'data': []};
}
}
}

View File

@ -1,12 +1,15 @@
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:pilipala/http/init.dart';
import 'package:pilipala/router/app_pages.dart';
import 'package:pilipala/pages/main/view.dart';
import 'package:pilipala/utils/storage.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await GStrorage.init();
await Request.setCookie();
runApp(const MyApp());
}
@ -23,14 +26,24 @@ class MyApp extends StatelessWidget {
return GetMaterialApp(
title: 'PiLiPaLa',
theme: ThemeData(
colorScheme: lightDynamic ??
ColorScheme.fromSeed(
seedColor: Colors.green, brightness: Brightness.light),
useMaterial3: true),
darkTheme: ThemeData(colorScheme: darkDynamic, useMaterial3: true),
colorScheme: lightDynamic ??
ColorScheme.fromSeed(
seedColor: Colors.green,
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: darkDynamic ??
ColorScheme.fromSeed(
seedColor: Colors.green,
brightness: Brightness.dark,
),
useMaterial3: true,
),
getPages: Routes.getPages,
home: const MainApp(),
// home: const Scaffold(),
builder: FlutterSmartDialog.init(),
);
}),
);

View File

@ -0,0 +1,46 @@
enum ReplyType {
unset,
// 视频
video,
// 话题
topic,
// 活动
activity,
// 小视频
videoS,
// 小黑屋封禁信息
blockMsg,
// 公告信息
publicMsg,
// 直播活动
liveActivity,
// 活动稿件
activityFile,
// 直播公告
livePublic,
// 相簿
album,
// 专栏
column,
// 票务
ticket,
// 音频
audio,
// 点评
comment,
// 动态
dynamics,
// 播单
playList,
// 音乐播单
musicPlayList,
// 漫画
comics1,
// 漫画
comics2,
// 漫画
comics3,
// 课程
course,
}

View File

@ -80,7 +80,9 @@ class HotVideoItemModel {
pubLocation = json["pub_location"];
seasontype = json["seasontype"];
isOgv = json["isOgv"];
rcmdReason = RcmdReason.fromJson(json['rcmd_reason']);
rcmdReason = json['rcmd_reason'] != ''
? RcmdReason.fromJson(json['rcmd_reason'])
: null;
}
}

View File

@ -13,6 +13,7 @@ class RecVideoItemModel {
this.pubdate,
this.owner,
this.stat,
this.isFollowed,
this.rcmdReason,
});
@ -27,6 +28,7 @@ class RecVideoItemModel {
int? pubdate = -1;
Owner? owner;
Stat? stat;
int? isFollowed;
RcmdReason? rcmdReason;
RecVideoItemModel.fromJson(Map<String, dynamic> json) {
@ -41,6 +43,7 @@ class RecVideoItemModel {
pubdate = json["pubdate"];
owner = Owner.fromJson(json["owner"]);
stat = Stat.fromJson(json["stat"]);
isFollowed = json["is_followed"] ?? 0;
rcmdReason = json["rcmd_reason"] != null
? RcmdReason.fromJson(json["rcmd_reason"])
: RcmdReason(content: '');

View File

@ -0,0 +1,100 @@
import 'package:pilipala/models/model_owner.dart';
class FavDetailData {
FavDetailData({
this.info,
this.medias,
this.hasMore,
});
Map? info;
List<FavDetailItemData>? medias;
bool? hasMore;
FavDetailData.fromJson(Map<String, dynamic> json) {
info = json['info'];
medias = json['medias'] != null
? json['medias']
.map<FavDetailItemData>((e) => FavDetailItemData.fromJson(e))
.toList()
: [FavDetailItemData()];
hasMore = json['has_more'];
}
}
class FavDetailItemData {
FavDetailItemData({
this.id,
this.type,
this.title,
this.pic,
this.intro,
this.page,
this.duration,
this.owner,
this.attr,
this.cntInfo,
this.link,
this.ctime,
this.pubdate,
this.favTime,
this.bvId,
this.bvid,
// this.season,
// this.ogv,
this.stat,
});
int? id;
int? type;
String? title;
String? pic;
String? intro;
int? page;
int? duration;
Owner? owner;
int? attr;
Map? cntInfo;
String? link;
int? ctime;
int? pubdate;
int? favTime;
String? bvId;
String? bvid;
Stat? stat;
FavDetailItemData.fromJson(Map<String, dynamic> json) {
id = json['id'];
type = json['type'];
title = json['title'];
pic = json['cover'];
intro = json['intro'];
page = json['page'];
duration = json['duration'];
owner = Owner.fromJson(json['upper']);
attr = json['attr'];
cntInfo = json['cnt_info'];
link = json['link'];
ctime = json['ctime'];
pubdate = json['pubtime'];
favTime = json['fav_time'];
bvId = json['bv_id'];
bvid = json['bvid'];
stat = Stat.fromJson(json['cnt_info']);
}
}
class Stat {
Stat({
this.view,
this.danmaku,
});
int? view;
int? danmaku;
Stat.fromJson(Map<String, dynamic> json) {
view = json['play'];
danmaku = json['danmaku'];
}
}

View File

@ -0,0 +1,108 @@
class FavFolderData {
FavFolderData({
this.count,
this.list,
this.hasMore,
});
int? count;
List<FavFolderItemData>? list;
bool? hasMore;
FavFolderData.fromJson(Map<String, dynamic> json) {
count = json['count'];
list = json['list'] != null
? json['list']
.map<FavFolderItemData>((e) => FavFolderItemData.fromJson(e))
.toList()
: [FavFolderItemData()];
hasMore = json['has_more'];
}
}
class FavFolderItemData {
FavFolderItemData({
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,
});
int? id;
int? fid;
int? mid;
int? attr;
String? title;
String? cover;
Upper? upper;
int? coverType;
String? intro;
int? ctime;
int? mtime;
int? state;
int? favState;
int? mediaCount;
int? viewCount;
int? vt;
int? playSwitch;
int? type;
String? link;
String? bvid;
FavFolderItemData.fromJson(Map<String, dynamic> json) {
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']) : Upper();
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 {
Upper({
this.mid,
this.name,
this.face,
});
int? mid;
String? name;
String? face;
Upper.fromJson(Map<String, dynamic> json) {
mid = json['mid'];
name = json['name'];
face = json['face'];
}
}

103
lib/models/user/info.dart Normal file
View File

@ -0,0 +1,103 @@
class UserInfoData {
UserInfoData({
this.isLogin,
this.emailVerified,
this.face,
this.levelInfo,
this.mid,
this.mobileVerified,
this.money,
this.moral,
this.official,
this.officialVerify,
this.pendant,
this.scores,
this.uname,
this.vipDueDate,
this.vipStatus,
this.vipType,
this.vipPayType,
this.vipThemeType,
this.vipLabel,
this.vipAvatarSub,
this.vipNicknameColor,
this.wallet,
this.hasShop,
this.shopUrl,
});
bool? isLogin;
int? emailVerified;
String? face;
LevelInfo? levelInfo;
int? mid;
int? mobileVerified;
int? money;
int? moral;
Map? official;
Map? officialVerify;
Map? pendant;
int? scores;
String? uname;
int? vipDueDate;
int? vipStatus;
int? vipType;
int? vipPayType;
int? vipThemeType;
Map? vipLabel;
int? vipAvatarSub;
String? vipNicknameColor;
Map? wallet;
bool? hasShop;
String? shopUrl;
UserInfoData.fromJson(Map<String, dynamic> json) {
isLogin = json['isLogin'] ?? false;
emailVerified = json['email_verified'];
face = json['face'];
levelInfo = json['level_info'] != null
? LevelInfo.fromJson(json['level_info'])
: LevelInfo();
mid = json['mid'];
mobileVerified = json['mobile_verified'];
money = json['money'];
moral = json['moral'];
official = json['official'];
officialVerify = json['officialVerify'];
pendant = json['pendant'];
scores = json['scores'];
uname = json['uname'];
vipDueDate = json['vipDueDate'];
vipStatus = json['vipStatus'];
vipType = json['vipType'];
vipPayType = json['vip_pay_type'];
vipThemeType = json['vip_theme_type'];
vipLabel = json['vip_label'];
vipAvatarSub = json['vip_avatar_subscript'];
vipNicknameColor = json['vip_nickname_color'];
wallet = json['wallet'];
hasShop = json['has_shop'];
shopUrl = json['shop_url'];
}
}
class LevelInfo {
LevelInfo({
this.currentLevel,
this.currentMin,
this.currentExp,
this.nextExp,
});
int? currentLevel;
int? currentMin;
int? currentExp;
int? nextExp;
LevelInfo.fromJson(Map<String, dynamic> json) {
currentLevel = json['current_level'];
currentMin = json['current_min'];
currentExp = json['current_exp'];
nextExp = json['next_exp'];
}
}

17
lib/models/user/stat.dart Normal file
View File

@ -0,0 +1,17 @@
class UserStat {
UserStat({
this.following,
this.follower,
this.dynamicCount,
});
int? following;
int? follower;
int? dynamicCount;
UserStat.fromJson(Map<String, dynamic> json) {
following = json['following'];
follower = json['follower'];
dynamicCount = json['dynamic_count'];
}
}

View File

@ -0,0 +1,17 @@
class ReplyConfig {
ReplyConfig({
this.showtopic,
this.showUpFlag,
this.readOnly,
});
int? showtopic;
bool? showUpFlag;
bool? readOnly;
ReplyConfig.fromJson(Map<String, dynamic> json) {
showtopic = json['showtopic'];
showUpFlag = json['show_up_flag'];
readOnly = json['read_only'];
}
}

View File

@ -0,0 +1,29 @@
class ReplyContent {
ReplyContent({
this.message,
this.atNameToMid, // @的用户的mid null
this.memebers, // 被@的用户List 如果有的话 []
this.emote, // 表情包 如果有的话 null
this.jumpUrl, // {}
this.pictures, // {}
this.vote,
});
String? message;
Map? atNameToMid;
List? memebers;
Map? emote;
Map? jumpUrl;
List? pictures;
Map? vote;
ReplyContent.fromJson(Map<String, dynamic> json) {
message = json['message'];
atNameToMid = json['at_name_to_mid'] ?? {};
memebers = json['memebers'] ?? [];
emote = json['emote'] ?? {};
jumpUrl = json['jump_url'] ?? {};
pictures = json['pictures'] ?? [];
vote = json['vote'] ?? {};
}
}

View File

@ -0,0 +1,40 @@
import 'package:pilipala/models/video/reply/item.dart';
import 'config.dart';
import 'page.dart';
import 'upper.dart';
class ReplyData {
ReplyData({
this.page,
this.config,
this.replies,
this.topReplies,
this.upper,
});
ReplyPage? page;
ReplyConfig? config;
late List<ReplyItemModel>? replies;
late List<ReplyItemModel>? topReplies;
ReplyUpper? upper;
ReplyData.fromJson(Map<String, dynamic> json) {
page = ReplyPage.fromJson(json['page']);
config = ReplyConfig.fromJson(json['config']);
replies = json['replies'] != null
? json['replies']
.map<ReplyItemModel>(
(item) => ReplyItemModel.fromJson(item, json['upper']['mid']))
.toList()
: [];
topReplies = json['top_replies'] != null
? json['top_replies']
.map<ReplyItemModel>((item) => ReplyItemModel.fromJson(
item, json['upper']['mid'],
isTopStatus: true))
.toList()
: [];
upper = ReplyUpper.fromJson(json['upper']);
}
}

View File

@ -0,0 +1,159 @@
import 'content.dart';
import 'member.dart';
class ReplyItemModel {
ReplyItemModel({
this.rpid,
this.oid,
this.type,
this.mid,
this.root,
this.parent,
this.dialog,
this.count,
this.floor,
this.state,
this.fansgrade,
this.attr,
this.ctime,
this.rpidStr,
this.rootStr,
this.parentStr,
this.like,
this.action,
this.member,
this.content,
this.replies,
this.assist,
this.upAction,
this.invisible,
this.replyControl,
this.isUp,
this.isTop,
this.cardLabel,
});
int? rpid;
int? oid;
int? type;
int? mid;
int? root;
int? parent;
int? dialog;
int? count;
int? floor;
int? state;
int? fansgrade;
int? attr;
int? ctime;
String? rpidStr;
String? rootStr;
String? parentStr;
int? like;
int? action;
ReplyMember? member;
ReplyContent? content;
List? replies;
int? assist;
UpAction? upAction;
bool? invisible;
ReplyControl? replyControl;
bool? isUp;
bool? isTop = false;
List? cardLabel;
ReplyItemModel.fromJson(Map<String, dynamic> json, upperMid,
{isTopStatus = false}) {
rpid = json['rpid'];
oid = json['oid'];
type = json['type'];
mid = json['mid'];
root = json['root'];
parent = json['parent'];
dialog = json['dialog'];
count = json['count'];
floor = json['floor'];
state = json['state'];
fansgrade = json['fansgrade'];
attr = json['attr'];
ctime = json['ctime'];
rpidStr = json['rpid_str'];
rootStr = json['root_str'];
parentStr = json['parent_str'];
like = json['like'];
action = json['action'];
member = ReplyMember.fromJson(json['member']);
content = ReplyContent.fromJson(json['content']);
replies = json['replies'] != null
? json['replies']
.map((item) => ReplyItemModel.fromJson(item, upperMid))
.toList()
: [];
assist = json['assist'];
upAction = UpAction.fromJson(json['up_action']);
invisible = json['invisible'];
replyControl = json['reply_control'] == null
? null
: ReplyControl.fromJson(json['reply_control']);
isUp = upperMid.toString() == json['member']['mid'];
isTop = isTopStatus;
cardLabel = json['card_label'] != null
? json['card_label'].map((e) => e['text_content']).toList()
: [];
}
}
class UpAction {
UpAction({this.like, this.reply});
bool? like;
bool? reply;
UpAction.fromJson(Map<String, dynamic> json) {
like = json['like'];
reply = json['reply'];
}
}
class ReplyControl {
ReplyControl({
this.upReply,
this.isUpTop,
this.upLike,
this.isShow,
this.entryText,
this.titleText,
this.time,
this.location,
});
bool? upReply;
bool? isUpTop;
bool? upLike;
bool? isShow;
String? entryText;
String? titleText;
String? time;
String? location;
ReplyControl.fromJson(Map<String, dynamic> json) {
upReply = json['up_reply'] ?? false;
isUpTop = json['is_up_top'] ?? false;
upLike = json['up_like'] ?? false;
if (json['sub_reply_entry_text'] != null) {
final RegExp regex = RegExp(r"\d+");
final RegExpMatch match = regex.firstMatch(
json['sub_reply_entry_text'] == null
? ''
: json['sub_reply_entry_text']!)!;
isShow = int.parse(match.group(0)!) >= 3;
} else {
isShow = false;
}
entryText = json['sub_reply_entry_text'];
titleText = json['sub_reply_title_text'];
time = json['time_desc'];
location = json['location'] != null ? json['location'].split('')[1] : '';
}
}

View File

@ -0,0 +1,71 @@
import 'package:get/get.dart';
class ReplyMember {
ReplyMember({
this.mid,
this.uname,
this.sign,
this.avatar,
this.level,
this.pendant,
this.officialVerify,
this.vip,
this.fansDetail,
});
String? mid;
String? uname;
String? sign;
String? avatar;
int? level;
Pendant? pendant;
Map? officialVerify;
Map? vip;
Map? fansDetail;
UserSailing? userSailing;
ReplyMember.fromJson(Map<String, dynamic> json) {
mid = json['mid'];
uname = json['uname'];
sign = json['sign'];
avatar = json['avatar'];
level = json['level_info']['current_level'];
pendant = Pendant.fromJson(json['pendant']);
officialVerify = json['officia_verify'];
vip = json['vip'];
fansDetail = json['fans_detail'];
userSailing = json['user_sailing'] != null
? UserSailing.fromJson(json['user_sailing'])
: UserSailing();
}
}
class Pendant {
Pendant({
this.pid,
this.name,
this.image,
});
int? pid;
String? name;
String? image;
Pendant.fromJson(Map<String, dynamic> json) {
pid = json['pid'];
name = json['name'];
image = json['image'];
}
}
class UserSailing {
UserSailing({this.pendant, this.cardbg});
Map? pendant;
Map? cardbg;
UserSailing.fromJson(Map<String, dynamic> json) {
pendant = json['pendant'];
cardbg = json['cardbg'];
}
}

View File

@ -0,0 +1,20 @@
class ReplyPage {
ReplyPage({
this.num,
this.size,
this.count,
this.acount,
});
int? num;
int? size;
int? count;
int? acount;
ReplyPage.fromJson(Map<String, dynamic> json) {
num = json['num'];
size = json['size'];
count = json['count'];
acount = json['acount'];
}
}

View File

@ -0,0 +1 @@
class ReplyTop {}

View File

@ -0,0 +1,18 @@
import 'item.dart';
class ReplyUpper {
ReplyUpper({
this.mid,
this.top,
});
int? mid;
ReplyItemModel? top;
ReplyUpper.fromJson(Map<String, dynamic> json) {
mid = json['mid'];
top = json['top'] != null
? ReplyItemModel.fromJson(json['top'], json['mid'])
: null;
}
}

View File

@ -0,0 +1,524 @@
import 'dart:convert';
class VideoDetailResponse {
int? code;
String? message;
int? ttl;
VideoDetailData? data;
VideoDetailResponse({
this.code,
this.message,
this.ttl,
this.data,
});
VideoDetailResponse.fromJson(Map<String, dynamic> json) {
code = json["code"];
message = json["message"];
ttl = json["ttl"];
data = json["data"] == null ? null : VideoDetailData.fromJson(json["data"]);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["code"] = code;
data["message"] = message;
data["ttl"] = ttl;
data["data"] = data;
return data;
}
}
class VideoDetailData {
String? bvid;
int? aid;
int? videos;
int? tid;
String? tname;
int? copyright;
String? pic;
String? title;
int? pubdate;
int? ctime;
String? desc;
List<DescV2>? descV2;
int? state;
int? duration;
Map<String, int>? rights;
Owner? owner;
Stat? stat;
String? videoDynamic;
int? cid;
Dimension? dimension;
dynamic premiere;
int? teenageMode;
bool? isChargeableSeason;
bool? isStory;
bool? noCache;
List<Page>? pages;
Subtitle? subtitle;
// Label? label;
bool? isSeasonDisplay;
UserGarb? userGarb;
HonorReply? honorReply;
String? likeIcon;
bool? needJumpBv;
VideoDetailData({
this.bvid,
this.aid,
this.videos,
this.tid,
this.tname,
this.copyright,
this.pic,
this.title,
this.pubdate,
this.ctime,
this.desc,
this.descV2,
this.state,
this.duration,
this.rights,
this.owner,
this.stat,
this.videoDynamic,
this.cid,
this.dimension,
this.premiere,
this.teenageMode,
this.isChargeableSeason,
this.isStory,
this.noCache,
this.pages,
this.subtitle,
this.isSeasonDisplay,
this.userGarb,
this.honorReply,
this.likeIcon,
this.needJumpBv,
});
VideoDetailData.fromJson(Map<String, dynamic> json) {
bvid = json["bvid"];
aid = json["aid"];
videos = json["videos"];
tid = json["tid"];
tname = json["tname"];
copyright = json["copyright"];
pic = json["pic"];
title = json["title"];
pubdate = json["pubdate"];
ctime = json["ctime"];
desc = json["desc"];
descV2 = json["desc_v2"] == null
? []
: List<DescV2>.from(json["desc_v2"]!.map((e) => DescV2.fromJson(e)));
state = json["state"];
duration = json["duration"];
rights =
Map.from(json["rights"]!).map((k, v) => MapEntry<String, int>(k, v));
owner = json["owner"] == null ? null : Owner.fromJson(json["owner"]);
stat = json["stat"] == null ? null : Stat.fromJson(json["stat"]);
videoDynamic = json["dynamic"];
cid = json["cid"];
dimension = json["dimension"] == null
? null
: Dimension.fromJson(json["dimension"]);
premiere = json["premiere"];
teenageMode = json["teenage_mode"];
isChargeableSeason = json["is_chargeable_season"];
isStory = json["is_story"];
noCache = json["no_cache"];
pages = json["pages"] == null
? []
: List<Page>.from(json["pages"]!.map((e) => Page.fromJson(e)));
subtitle =
json["subtitle"] == null ? null : Subtitle.fromJson(json["subtitle"]);
isSeasonDisplay = json["is_season_display"];
userGarb =
json["user_garb"] == null ? null : UserGarb.fromJson(json["user_garb"]);
honorReply = json["honor_reply"] == null
? null
: HonorReply.fromJson(json["honor_reply"]);
likeIcon = json["like_icon"];
needJumpBv = json["need_jump_bv"];
}
Map<String, dynamic> toJson() => {
"bvid": bvid,
"aid": aid,
"videos": videos,
"tid": tid,
"tname": tname,
"copyright": copyright,
"pic": pic,
"title": title,
"pubdate": pubdate,
"ctime": ctime,
"desc": desc,
"desc_v2": descV2 == null
? []
: List<dynamic>.from(descV2!.map((e) => e.toJson())),
"state": state,
"duration": duration,
"rights":
Map.from(rights!).map((k, v) => MapEntry<String, dynamic>(k, v)),
"owner": owner?.toJson(),
"stat": stat?.toJson(),
"dynamic": videoDynamic,
"cid": cid,
"dimension": dimension?.toJson(),
"premiere": premiere,
"teenage_mode": teenageMode,
"is_chargeable_season": isChargeableSeason,
"is_story": isStory,
"no_cache": noCache,
"pages": pages == null
? []
: List<dynamic>.from(pages!.map((e) => e.toJson())),
"subtitle": subtitle?.toJson(),
"is_season_display": isSeasonDisplay,
"user_garb": userGarb?.toJson(),
"honor_reply": honorReply?.toJson(),
"like_icon": likeIcon,
"need_jump_bv": needJumpBv,
};
}
class DescV2 {
String? rawText;
int? type;
int? bizId;
DescV2({
this.rawText,
this.type,
this.bizId,
});
fromRawJson(String str) {
return DescV2.fromJson(json.decode(str));
}
String toRawJson() => json.encode(toJson());
DescV2.fromJson(Map<String, dynamic> json) {
rawText = json["raw_text"];
type = json["type"];
bizId = json["biz_id"];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["raw_text"] = rawText;
data["type"] = type;
data["biz_id"] = bizId;
return data;
}
}
class Dimension {
int? width;
int? height;
int? rotate;
Dimension({
this.width,
this.height,
this.rotate,
});
fromRawJson(String str) => Dimension.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
Dimension.fromJson(Map<String, dynamic> json) {
width = json["width"];
height = json["height"];
rotate = json["rotate"];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["width"] = width;
data["height"] = height;
data["rotate"] = rotate;
data["data"] = data;
return data;
}
}
class HonorReply {
List<Honor>? honor;
HonorReply({
this.honor,
});
fromRawJson(String str) => HonorReply.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
HonorReply.fromJson(Map<String, dynamic> json) {
honor = json["honor"] == null
? []
: List<Honor>.from(json["honor"]!.map((x) => Honor.fromJson(x)));
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["honor"] =
honor == null ? [] : List<dynamic>.from(honor!.map((x) => x.toJson()));
return data;
}
}
class Honor {
int? aid;
int? type;
String? desc;
int? weeklyRecommendNum;
Honor({
this.aid,
this.type,
this.desc,
this.weeklyRecommendNum,
});
fromRawJson(String str) => Honor.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
Honor.fromJson(Map<String, dynamic> json) {
aid = json["aid"];
type = json["type"];
desc = json["desc"];
weeklyRecommendNum = json["weekly_recommend_num"];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["aid"] = aid;
data["type"] = type;
data["desc"] = desc;
data["weekly_recommend_num"] = weeklyRecommendNum;
return data;
}
}
class Owner {
int? mid;
String? name;
String? face;
Owner({
this.mid,
this.name,
this.face,
});
fromRawJson(String str) => Owner.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
Owner.fromJson(Map<String, dynamic> json) {
mid = json["mid"];
name = json["name"];
face = json["face"];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["mid"] = mid;
data["name"] = name;
data["face"] = face;
return data;
}
}
class Page {
int? cid;
int? page;
String? from;
String? pagePart;
int? duration;
String? vid;
String? weblink;
Dimension? dimension;
String? firstFrame;
Page({
this.cid,
this.page,
this.from,
this.pagePart,
this.duration,
this.vid,
this.weblink,
this.dimension,
this.firstFrame,
});
fromRawJson(String str) => Page.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
Page.fromJson(Map<String, dynamic> json) {
cid = json["cid"];
page = json["page"];
from = json["from"];
pagePart = json["part"];
duration = json["duration"];
vid = json["vid"];
weblink = json["weblink"];
dimension = json["dimension"] == null
? null
: Dimension.fromJson(json["dimension"]);
firstFrame = json["first_frame"];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["cid"] = cid;
data["page"] = page;
data["from"] = from;
data["part"] = pagePart;
data["duration"] = duration;
data["vid"] = vid;
data["weblink"] = weblink;
data["dimension"] = dimension?.toJson();
data["first_frame"] = firstFrame;
return data;
}
}
class Stat {
int? aid;
int? view;
int? danmaku;
int? reply;
int? favorite;
int? coin;
int? share;
int? nowRank;
int? hisRank;
int? like;
int? dislike;
String? evaluation;
String? argueMsg;
Stat({
this.aid,
this.view,
this.danmaku,
this.reply,
this.favorite,
this.coin,
this.share,
this.nowRank,
this.hisRank,
this.like,
this.dislike,
this.evaluation,
this.argueMsg,
});
fromRawJson(String str) => Stat.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
Stat.fromJson(Map<String, dynamic> json) {
aid = json["aid"];
view = json["view"];
danmaku = json["danmaku"];
reply = json["reply"];
favorite = json["favorite"];
coin = json["coin"];
share = json["share"];
nowRank = json["now_rank"];
hisRank = json["his_rank"];
like = json["like"];
dislike = json["dislike"];
evaluation = json["evaluation"];
argueMsg = json["argue_msg"];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["aid"] = aid;
data["view"] = view;
data["danmaku"] = danmaku;
data["reply"] = reply;
data["favorite"] = favorite;
data["coin"] = coin;
data["share"] = share;
data["now_rank"] = nowRank;
data["his_rank"] = hisRank;
data["like"] = like;
data["dislike"] = dislike;
data["evaluation"] = evaluation;
data["argue_msg"] = argueMsg;
return data;
}
}
class Subtitle {
bool? allowSubmit;
List<dynamic>? list;
Subtitle({
this.allowSubmit,
this.list,
});
fromRawJson(String str) => Subtitle.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
Subtitle.fromJson(Map<String, dynamic> json) {
allowSubmit = json["allow_submit"];
list = json["list"] == null
? []
: List<dynamic>.from(json["list"]!.map((x) => x));
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["allow_submit"] = allowSubmit;
data["list"] = list == null ? [] : List<dynamic>.from(list!.map((x) => x));
return data;
}
}
class UserGarb {
String? urlImageAniCut;
UserGarb({
this.urlImageAniCut,
});
fromRawJson(String str) => UserGarb.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
UserGarb.fromJson(Map<String, dynamic> json) {
urlImageAniCut = json["url_image_ani_cut"];
}
Map<String, dynamic> toJson() => {"url_image_ani_cut": urlImageAniCut};
}
class Label {}

View File

@ -0,0 +1,18 @@
import 'package:get/get.dart';
import 'package:pilipala/http/user.dart';
import 'package:pilipala/models/user/fav_folder.dart';
import 'package:pilipala/utils/storage.dart';
class FavController extends GetxController {
Rx<FavFolderData> favFolderData = FavFolderData().obs;
Future<dynamic> queryFavFolder() async {
var res = await await UserHttp.userfavFolder(
pn: 1,
ps: 10,
mid: GStrorage.user.get(UserBoxKey.userMid),
);
favFolderData.value = res['data'];
return res;
}
}

4
lib/pages/fav/index.dart Normal file
View File

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

75
lib/pages/fav/view.dart Normal file
View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/pages/fav/index.dart';
class FavPage extends StatefulWidget {
const FavPage({super.key});
@override
State<FavPage> createState() => _FavPageState();
}
class _FavPageState extends State<FavPage> {
final FavController _favController = Get.put(FavController());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: false,
title: const Text('我的收藏'),
),
body: FutureBuilder(
future: _favController.queryFavFolder(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data as Map;
if (data['status']) {
return Obx(
() => ListView.builder(
itemCount: _favController.favFolderData.value.list!.length,
itemBuilder: (context, index) {
return ListTile(
onTap: () => Get.toNamed(
'/favDetail',
arguments:
_favController.favFolderData.value.list![index],
parameters: {
'mediaId': _favController
.favFolderData.value.list![index].id
.toString(),
},
),
leading: const Icon(Icons.folder_special_outlined),
minLeadingWidth: 0,
title: Text(_favController
.favFolderData.value.list![index].title!),
subtitle: Text(
'${_favController.favFolderData.value.list![index].mediaCount}个内容',
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
fontSize: Theme.of(context)
.textTheme
.labelSmall!
.fontSize),
),
);
},
),
);
} else {
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return Text('请求中');
}
},
),
);
}
}

View File

@ -0,0 +1,50 @@
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/user.dart';
import 'package:pilipala/http/video.dart';
import 'package:pilipala/models/user/fav_detail.dart';
import 'package:pilipala/models/user/fav_folder.dart';
class FavDetailController extends GetxController {
FavFolderItemData? item;
Rx<FavDetailData> favDetailData = FavDetailData().obs;
int? mediaId;
@override
void onInit() {
item = Get.arguments;
if (Get.parameters.keys.isNotEmpty) {
mediaId = int.parse(Get.parameters['mediaId']!);
}
super.onInit();
}
Future<dynamic> queryUserFavFolderDetail() async {
var res = await await UserHttp.userFavFolderDetail(
pn: 1,
ps: 15,
mediaId: mediaId!,
);
favDetailData.value = res['data'];
return res;
}
onCancelFav(int id) async {
var result = await VideoHttp.favVideo(
aid: id.toString(), addIds: '', delIds: mediaId.toString());
if (result['status']) {
if (result['data']['prompt']) {
List<FavDetailItemData> dataList = favDetailData.value.medias!;
for (var i in dataList) {
if (i.id == id) {
dataList.remove(i);
break;
}
}
favDetailData.value.medias = dataList;
favDetailData.refresh();
SmartDialog.showToast('取消收藏');
}
}
}
}

View File

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

View File

@ -0,0 +1,219 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/pages/favDetail/index.dart';
import 'widget/fav_video_card.dart';
class FavDetailPage extends StatefulWidget {
const FavDetailPage({super.key});
@override
State<FavDetailPage> createState() => _FavDetailPageState();
}
class _FavDetailPageState extends State<FavDetailPage> {
late final ScrollController _controller = ScrollController();
final FavDetailController _favDetailController =
Get.put(FavDetailController());
late StreamController<bool> titleStreamC; // a
@override
void initState() {
super.initState();
titleStreamC = StreamController<bool>();
_controller.addListener(
() {
if (_controller.offset > 160) {
titleStreamC.add(true);
} else if (_controller.offset <= 160) {
titleStreamC.add(false);
}
},
);
}
@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,
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(
_favDetailController.item!.title!,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${_favDetailController.item!.mediaCount!}条视频',
style: Theme.of(context).textTheme.labelMedium,
)
],
)
],
),
);
},
),
// actions: [
// IconButton(
// onPressed: () {},
// icon: const Icon(Icons.more_vert),
// ),
// const SizedBox(width: 4)
// ],
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: [
SizedBox(
width: 180,
height: 110,
child: NetworkImgLayer(
width: 180,
height: 110,
src: _favDetailController.item!.cover,
),
),
const SizedBox(width: 14),
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
_favDetailController.item!.title!,
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.titleMedium!
.fontSize,
fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
_favDetailController.item!.upper!.name!,
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(
'${_favDetailController.favDetailData.value.medias != null ? _favDetailController.favDetailData.value.medias!.length : '-'}条视频',
style: TextStyle(
fontSize:
Theme.of(context).textTheme.labelMedium!.fontSize,
color: Theme.of(context).colorScheme.outline,
letterSpacing: 1),
),
),
),
),
FutureBuilder(
future: _favDetailController.queryUserFavFolderDetail(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data;
if (data['status']) {
if (_favDetailController.item!.mediaCount == 0) {
return const SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: Center(child: Text('没有内容')),
),
);
} else {
return Obx(
() => SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return FavVideoCardH(
videoItem: _favDetailController
.favDetailData.value.medias![index],
);
},
childCount: _favDetailController
.favDetailData.value.medias!.length),
),
);
}
} else {
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
return const SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: Center(child: Text('加载中')),
),
);
}
},
),
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).padding.bottom + 20,
),
)
],
),
);
}
}

View File

@ -0,0 +1,165 @@
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/utils/utils.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import '../controller.dart';
// 收藏视频卡片 - 水平布局
class FavVideoCardH extends StatelessWidget {
var videoItem;
final FavDetailController _favDetailController =
Get.put(FavDetailController());
FavVideoCardH({Key? key, required this.videoItem}) : super(key: key);
@override
Widget build(BuildContext context) {
int id = videoItem.id;
String heroTag = Utils.makeHeroTag(id);
return Dismissible(
movementDuration: const Duration(milliseconds: 300),
background: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.clear_all_rounded),
SizedBox(width: 6),
Text('取消收藏')
],
)),
direction: DismissDirection.endToStart,
key: ValueKey<int>(videoItem.id),
onDismissed: (DismissDirection direction) {
_favDetailController.onCancelFav(videoItem.id);
// widget.onDeleteNotice();
},
child: InkWell(
onTap: () async {
await Future.delayed(const Duration(milliseconds: 200));
Get.toNamed('/video?aid=$id',
arguments: {'videoItem': videoItem, 'heroTag': heroTag});
},
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 5, 12, 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;
double PR =
MediaQuery.of(context).devicePixelRatio;
return Stack(
children: [
Hero(
tag: heroTag,
child: NetworkImgLayer(
// src: videoItem['pic'] +
// '@${(maxWidth * 2).toInt()}w',
src: videoItem.pic + '@.webp',
width: maxWidth,
height: maxHeight,
),
),
// Image.network( videoItem['pic'], width: double.infinity, height: double.infinity,),
Positioned(
right: 4,
bottom: 4,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 1, horizontal: 6),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(4),
color:
Colors.black54.withOpacity(0.4)),
child: Text(
Utils.timeFormat(videoItem.duration!),
style: const TextStyle(
fontSize: 11, color: Colors.white),
),
),
)
],
);
},
),
),
VideoContent(videoItem: videoItem)
],
),
);
},
),
),
],
),
),
);
}
}
class VideoContent extends StatelessWidget {
final videoItem;
const VideoContent({super.key, required this.videoItem});
@override
Widget build(BuildContext context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 2, 6, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
videoItem.title,
textAlign: TextAlign.start,
style: TextStyle(
fontSize: Theme.of(context).textTheme.titleSmall!.fontSize,
fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
Text(
videoItem.owner.name,
style: TextStyle(
fontSize: Theme.of(context).textTheme.labelSmall!.fontSize,
color: Theme.of(context).colorScheme.outline,
),
),
Row(
children: [
StatView(
theme: 'gray',
view: videoItem.cntInfo['play'],
),
const SizedBox(width: 8),
StatDanMu(theme: 'gray', danmu: videoItem.cntInfo['danmaku'])
],
),
],
),
),
);
}
}

View File

@ -1,7 +1,6 @@
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/api.dart';
import 'package:pilipala/http/init.dart';
import 'package:pilipala/http/video.dart';
import 'package:pilipala/models/model_rec_video_item.dart';
class HomeController extends GetxController {
@ -17,28 +16,27 @@ class HomeController extends GetxController {
@override
void onInit() {
super.onInit();
queryRcmdFeed('init');
// queryRcmdFeed('init');
}
// 获取推荐
Future queryRcmdFeed(type) async {
var res = await Request().get(
Api.recommendList,
data: {'feed_version': "V3", 'ps': count, 'fresh_idx': _currentPage},
var res = await VideoHttp.rcmdVideoList(
ps: count,
freshIdx: _currentPage,
);
List<RecVideoItemModel> list = [];
for (var i in res.data['data']['item']) {
list.add(RecVideoItemModel.fromJson(i));
if (res['status']) {
if (type == 'init') {
videoList.value = res['data'];
} else if (type == 'onRefresh') {
videoList.insertAll(0, res['data']);
} else if (type == 'onLoad') {
videoList.addAll(res['data']);
}
_currentPage += 1;
}
if (type == 'init') {
videoList.value = list;
} else if (type == 'onRefresh') {
videoList.insertAll(0, list);
} else if (type == 'onLoad') {
videoList.addAll(list);
}
_currentPage += 1;
isLoadingMore = false;
return res;
}
// 下拉刷新
@ -48,7 +46,6 @@ class HomeController extends GetxController {
// 上拉加载
Future onLoad() async {
await Future.delayed(const Duration(milliseconds: 500));
queryRcmdFeed('onLoad');
}

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:pilipala/common/skeleton/video_card_v.dart';
import 'package:pilipala/common/widgets/animated_dialog.dart';
import 'package:pilipala/common/widgets/overlay_pop.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/common/widgets/video_card_v.dart';
import './controller.dart';
import 'package:pilipala/common/constants.dart';
@ -18,6 +19,7 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage>
with AutomaticKeepAliveClientMixin {
final HomeController _homeController = Get.put(HomeController());
Future? _futureBuilderFuture;
List videoList = [];
@override
@ -26,6 +28,7 @@ class _HomePageState extends State<HomePage>
@override
void initState() {
super.initState();
_futureBuilderFuture = _homeController.queryRcmdFeed('init');
_homeController.videoList.listen((value) {
videoList = value;
setState(() {});
@ -71,37 +74,25 @@ class _HomePageState extends State<HomePage>
? EdgeInsets.zero
: const EdgeInsets.fromLTRB(
StyleString.cardSpace, 0, StyleString.cardSpace, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
// 行间距
mainAxisSpacing: StyleString.cardSpace,
// 列间距
crossAxisSpacing: StyleString.cardSpace,
// 列数
crossAxisCount: _homeController.crossAxisCount,
mainAxisExtent: MediaQuery.of(context).size.width /
_homeController.crossAxisCount /
StyleString.aspectRatio +
72),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return videoList.isNotEmpty
? VideoCardV(
videoItem: videoList[index],
longPress: () {
_homeController.popupDialog =
_createPopupDialog(videoList[index]);
Overlay.of(context)
.insert(_homeController.popupDialog!);
},
longPressEnd: () {
_homeController.popupDialog?.remove();
},
)
: const VideoCardVSkeleton();
},
childCount: videoList.isNotEmpty ? videoList.length : 10,
),
sliver: FutureBuilder(
future: _futureBuilderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data as Map;
if (data['status']) {
return Obx(() => contentGrid(
_homeController, _homeController.videoList));
} else {
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return contentGrid(_homeController, []);
}
},
),
),
const LoadingMore()
@ -114,8 +105,44 @@ class _HomePageState extends State<HomePage>
OverlayEntry _createPopupDialog(videoItem) {
return OverlayEntry(
builder: (context) => AnimatedDialog(
child: OverlayPop(videoItem: videoItem),
builder: (context) => AnimatedDialog(
child: OverlayPop(videoItem: videoItem),
));
}
Widget contentGrid(ctr, videoList) {
return SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
// 行间距
mainAxisSpacing: StyleString.cardSpace,
// 列间距
crossAxisSpacing: StyleString.cardSpace,
// 列数
crossAxisCount: ctr.crossAxisCount,
mainAxisExtent: MediaQuery.of(context).size.width /
ctr.crossAxisCount /
StyleString.aspectRatio +
70,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return videoList!.isNotEmpty
?
// VideoCardV(videoItem: videoList![index])
VideoCardV(
videoItem: videoList[index],
longPress: () {
_homeController.popupDialog =
_createPopupDialog(videoList[index]);
Overlay.of(context).insert(_homeController.popupDialog!);
},
longPressEnd: () {
_homeController.popupDialog?.remove();
},
)
: const VideoCardVSkeleton();
},
childCount: videoList!.isNotEmpty ? videoList!.length : 10,
),
);
}

View File

@ -1,5 +1,9 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:pilipala/pages/mine/view.dart';
class HomeAppBar extends StatelessWidget {
const HomeAppBar({super.key});
@ -9,11 +13,7 @@ class HomeAppBar extends StatelessWidget {
return SliverAppBar(
// forceElevated: true,
scrolledUnderElevation: 0,
toolbarHeight: Platform.isAndroid
? (MediaQuery.of(context).padding.top + 6)
: Platform.isIOS
? MediaQuery.of(context).padding.top - 2
: kToolbarHeight,
toolbarHeight: MediaQuery.of(context).padding.top,
expandedHeight: kToolbarHeight + MediaQuery.of(context).padding.top,
automaticallyImplyLeading: false,
pinned: true,
@ -29,19 +29,26 @@ class HomeAppBar extends StatelessWidget {
title: const Text(
'PiLiPaLa',
style: TextStyle(
fontSize: 18,
fontSize: 20,
fontWeight: FontWeight.bold,
letterSpacing: 1,
fontFamily: 'ArchivoNarrow',
),
),
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.notifications_none_rounded),
icon: const Icon(CupertinoIcons.search, size: 22),
),
// IconButton(
// onPressed: () {},
// icon: const Icon(CupertinoIcons.bell, size: 22),
// ),
IconButton(
onPressed: () {},
icon: const Icon(Icons.search_rounded),
onPressed: () {
Get.bottomSheet(const MinePage());
},
icon: const Icon(CupertinoIcons.person, size: 22),
),
const SizedBox(width: 10)
],

View File

@ -1,8 +1,6 @@
import 'package:flutter/animation.dart';
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/api.dart';
import 'package:pilipala/http/init.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/http/video.dart';
import 'package:pilipala/models/model_hot_video_item.dart';
class HotController extends GetxController {
@ -14,31 +12,24 @@ class HotController extends GetxController {
bool flag = false;
OverlayEntry? popupDialog;
@override
void onInit() {
super.onInit();
queryHotFeed('init');
}
// 获取推荐
Future queryHotFeed(type) async {
var res = await Request().get(
Api.hotList,
data: {'pn': _currentPage, 'ps': _count},
var res = await VideoHttp.hotVideoList(
pn: _currentPage,
ps: _count,
);
List<HotVideoItemModel> list = [];
for (var i in res.data['data']['list']) {
list.add(HotVideoItemModel.fromJson(i));
if (res['status']) {
if (type == 'init') {
videoList.value = res['data'];
} else if (type == 'onRefresh') {
videoList.insertAll(0, res['data']);
} else if (type == 'onLoad') {
videoList.addAll(res['data']);
}
_currentPage += 1;
}
if (type == 'init') {
videoList.value = list;
} else if (type == 'onRefresh') {
videoList.insertAll(0, list);
} else if (type == 'onLoad') {
videoList.addAll(list);
}
_currentPage += 1;
isLoadingMore = false;
return res;
}
// 下拉刷新

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/widgets/animated_dialog.dart';
import 'package:pilipala/common/widgets/overlay_pop.dart';
import 'package:pilipala/common/skeleton/video_card_h.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/common/widgets/video_card_h.dart';
import 'package:pilipala/pages/hot/controller.dart';
import 'package:pilipala/pages/home/widgets/app_bar.dart';
@ -16,6 +18,7 @@ class HotPage extends StatefulWidget {
class _HotPageState extends State<HotPage> with AutomaticKeepAliveClientMixin {
final HotController _hotController = Get.put(HotController());
List videoList = [];
Future? _futureBuilderFuture;
@override
bool get wantKeepAlive => true;
@ -23,11 +26,7 @@ class _HotPageState extends State<HotPage> with AutomaticKeepAliveClientMixin {
@override
void initState() {
super.initState();
_hotController.videoList.listen((value) {
videoList = value;
setState(() {});
});
_futureBuilderFuture = _hotController.queryHotFeed('init');
_hotController.scrollController.addListener(
() {
if (_hotController.scrollController.position.pixels >=
@ -54,20 +53,46 @@ class _HotPageState extends State<HotPage> with AutomaticKeepAliveClientMixin {
controller: _hotController.scrollController,
slivers: [
const HomeAppBar(),
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return VideoCardH(
videoItem: videoList[index],
longPress: () {
_hotController.popupDialog =
_createPopupDialog(videoList[index]);
Overlay.of(context).insert(_hotController.popupDialog!);
},
longPressEnd: () {
_hotController.popupDialog?.remove();
},
);
}, childCount: videoList.length)),
FutureBuilder(
future: _futureBuilderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data as Map;
if (data['status']) {
return Obx(
() => SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return VideoCardH(
videoItem: _hotController.videoList[index],
longPress: () {
_hotController.popupDialog = _createPopupDialog(
_hotController.videoList[index]);
Overlay.of(context)
.insert(_hotController.popupDialog!);
},
longPressEnd: () {
_hotController.popupDialog?.remove();
},
);
}, childCount: _hotController.videoList.length),
),
);
} else {
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return const VideoCardHSkeleton();
}, childCount: 5),
);
}
},
),
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).padding.bottom + 10,

View File

@ -1,30 +1,97 @@
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/pages/home/view.dart';
import 'package:pilipala/pages/hot/view.dart';
import 'package:pilipala/pages/mine/view.dart';
import 'package:pilipala/pages/media/index.dart';
import 'package:pilipala/utils/storage.dart';
class MainController extends GetxController {
List<Widget> pages = <Widget>[
const HomePage(),
const HotPage(),
const MinePage(),
const MediaPage(),
];
List navigationBars = [
RxList navigationBars = [
{
'icon': const Icon(Icons.home_outlined),
'selectedIcon': const Icon(Icons.home),
// 'icon': const Icon(Icons.home_outlined),
// 'selectedIcon': const Icon(Icons.home),
'icon': const Icon(
CupertinoIcons.square_favorites_alt,
size: 21,
),
'selectedIcon': const Icon(
CupertinoIcons.square_favorites_alt_fill,
size: 21,
),
'label': "推荐",
},
{
'icon': const Icon(Icons.whatshot_outlined),
'selectedIcon': const Icon(Icons.whatshot_rounded),
// 'icon': const Icon(Icons.whatshot_outlined),
// 'selectedIcon': const Icon(Icons.whatshot_rounded),
'icon': const Icon(
CupertinoIcons.flame,
size: 20,
),
'selectedIcon': const Icon(
CupertinoIcons.flame_fill,
size: 20,
),
'label': "热门",
},
// {
// 'icon': const Icon(
// CupertinoIcons.person,
// size: 21,
// ),
// 'selectedIcon': const Icon(
// CupertinoIcons.person_fill,
// size: 21,
// ),
// 'label': "我的",
// },
{
'icon': const Icon(Icons.person_outline),
'selectedIcon': const Icon(Icons.person),
'label': "我的",
// 'icon': const Icon(Icons.person_outline),
// 'selectedIcon': const Icon(Icons.person),
'icon': const Icon(
CupertinoIcons.folder,
size: 20,
),
'selectedIcon': const Icon(
CupertinoIcons.folder_fill,
size: 20,
),
'label': "媒体库",
}
];
].obs;
@override
void onInit() {
super.onInit();
// readuUserFace();
}
// 设置头像
// readuUserFace() async {
// Box user = GStrorage.user;
// if (user.get(UserBoxKey.userFace) != null) {
// navigationBars.last['icon'] =
// navigationBars.last['selectedIcon'] = NetworkImgLayer(
// width: 25,
// height: 25,
// type: 'avatar',
// src: user.get(UserBoxKey.userFace),
// );
// navigationBars.last['label'] = '我';
// }
// }
// 重置
// resetLast() {
// navigationBars.last['icon'] = const Icon(Icons.person_outline);
// navigationBars.last['selectedIcon'] = const Icon(Icons.person);
// navigationBars.last['label'] = '我的';
// }
}

View File

@ -15,6 +15,7 @@ class _MainAppState extends State<MainApp> with SingleTickerProviderStateMixin {
final MainController _mainController = Get.put(MainController());
final HomeController _homeController = Get.put(HomeController());
final HotController _hotController = Get.put(HotController());
PageController? _pageController;
late AnimationController? _animationController;
late Animation<double>? _fadeAnimation;
@ -36,6 +37,7 @@ class _MainAppState extends State<MainApp> with SingleTickerProviderStateMixin {
_slideAnimation =
Tween(begin: 0.8, end: 1.0).animate(_animationController!);
_lastSelectTime = DateTime.now().millisecondsSinceEpoch;
_pageController = PageController(initialPage: selectedIndex);
}
void setIndex(int value) async {
@ -47,7 +49,7 @@ class _MainAppState extends State<MainApp> with SingleTickerProviderStateMixin {
});
setState(() {});
}
_pageController!.jumpToPage(value);
var currentPage = _mainController.pages[value];
if (currentPage is HomePage) {
if (_homeController.flag) {
@ -98,23 +100,30 @@ class _MainAppState extends State<MainApp> with SingleTickerProviderStateMixin {
reverseCurve: Curves.linear,
),
),
child: IndexedStack(
index: selectedIndex,
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (index) {
selectedIndex = index;
setState(() {});
},
children: _mainController.pages,
),
),
),
bottomNavigationBar: NavigationBar(
elevation: 1,
destinations: _mainController.navigationBars.map((e) {
return NavigationDestination(
icon: e['icon'],
selectedIcon: e['selectedIcon'],
label: e['label'],
);
}).toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (value) => setIndex(value),
bottomNavigationBar: Obx(
() => NavigationBar(
elevation: 1,
destinations: _mainController.navigationBars.map((e) {
return NavigationDestination(
icon: e['icon'],
selectedIcon: e['selectedIcon'],
label: e['label'],
);
}).toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (value) => setIndex(value),
),
),
);
}

View File

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/user.dart';
import 'package:pilipala/models/user/fav_folder.dart';
import 'package:pilipala/utils/storage.dart';
class MediaController extends GetxController {
Rx<FavFolderData> favFolderData = FavFolderData().obs;
List list = [
{
'icon': Icons.file_download_outlined,
'title': '离线缓存',
'onTap': () {},
},
{
'icon': Icons.history,
'title': '观看记录',
'onTap': () {},
},
{
'icon': Icons.star_border,
'title': '我的收藏',
'onTap': () => Get.toNamed('/fav'),
},
{
'icon': Icons.watch_later_outlined,
'title': '稍候再看',
'onTap': () => {},
},
];
Future<dynamic> queryFavFolder() async {
var res = await await UserHttp.userfavFolder(
pn: 1,
ps: 5,
mid: GStrorage.user.get(UserBoxKey.userMid),
);
favFolderData.value = res['data'];
return res;
}
}

View File

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

242
lib/pages/media/view.dart Normal file
View File

@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/user/fav_folder.dart';
import 'package:pilipala/pages/media/index.dart';
class MediaPage extends StatefulWidget {
const MediaPage({super.key});
@override
State<MediaPage> createState() => _MediaPageState();
}
class _MediaPageState extends State<MediaPage>
with AutomaticKeepAliveClientMixin {
final MediaController _mediaController = Get.put(MediaController());
Future? _futureBuilderFuture;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_futureBuilderFuture = _mediaController.queryFavFolder();
}
@override
Widget build(BuildContext context) {
Color primary = Theme.of(context).colorScheme.primary;
return Scaffold(
appBar: AppBar(toolbarHeight: 30),
body: Column(
children: [
ListTile(
leading: null,
title: Padding(
padding: const EdgeInsets.only(left: 20),
child: Text(
'媒体库',
style: TextStyle(
fontSize: Theme.of(context).textTheme.titleLarge!.fontSize,
fontWeight: FontWeight.bold,
),
),
),
),
for (var i in _mediaController.list) ...[
ListTile(
onTap: () => i['onTap'](),
dense: true,
leading: Padding(
padding: const EdgeInsets.only(left: 15),
child: Icon(
i['icon'],
color: primary,
),
),
contentPadding:
const EdgeInsets.only(left: 15, top: 2, bottom: 2),
minLeadingWidth: 0,
title: Text(
i['title'],
style: const TextStyle(fontSize: 15),
),
),
],
favFolder()
],
),
);
}
Widget favFolder() {
return Column(
children: [
Divider(
height: 35,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
ListTile(
onTap: () {},
leading: null,
dense: true,
title: Padding(
padding: const EdgeInsets.only(left: 10),
child: Obx(
() => Text.rich(
TextSpan(
children: [
TextSpan(
text: '收藏夹 ',
style: TextStyle(
fontSize:
Theme.of(context).textTheme.titleMedium!.fontSize,
fontWeight: FontWeight.bold),
),
if (_mediaController.favFolderData.value.count != null)
TextSpan(
text: _mediaController.favFolderData.value.count
.toString(),
style: TextStyle(
fontSize:
Theme.of(context).textTheme.titleSmall!.fontSize,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
),
trailing: IconButton(
onPressed: () => _mediaController.queryFavFolder(),
icon: const Icon(
Icons.refresh,
size: 20,
),
),
),
// const SizedBox(height: 10),
SizedBox(
width: double.infinity,
height: 170,
child: FutureBuilder(
future: _futureBuilderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data;
if (data['status']) {
List favFolderList =
_mediaController.favFolderData.value.list!;
int favFolderCount =
_mediaController.favFolderData.value.count!;
bool flag = favFolderCount > favFolderList.length;
return Obx(() => ListView.builder(
itemCount: _mediaController
.favFolderData.value.list!.length +
(flag ? 1 : 0),
itemBuilder: (context, index) {
if (flag && index == favFolderList.length) {
return Padding(
padding: const EdgeInsets.only(
right: 14, bottom: 35),
child: Center(
child: IconButton(
onPressed: () => Get.toNamed('/fav'),
icon: Icon(
Icons.arrow_forward_ios,
size: 18,
color: Theme.of(context).primaryColor,
),
),
));
} else {
return FavFolderItem(
item: _mediaController
.favFolderData.value.list![index],
index: index);
}
},
scrollDirection: Axis.horizontal,
));
} else {
return SizedBox(
height: 160,
child: Center(child: Text(data['msg'])),
);
}
} else {
// 骨架屏
return SizedBox();
}
}),
),
],
);
}
}
class FavFolderItem extends StatelessWidget {
FavFolderItem({super.key, this.item, this.index});
FavFolderItemData? item;
int? index;
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(left: index == 0 ? 20 : 0, right: 14),
child: GestureDetector(
onTap: () => Get.toNamed('/favDetail', arguments: item, parameters: {
'mediaId': item!.id.toString(),
}),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Container(
width: 180,
height: 110,
margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.onInverseSurface,
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.onInverseSurface,
offset: const Offset(4, -12), // 阴影与容器的距离
blurRadius: 0.0, // 高斯的标准偏差与盒子的形状卷积。
spreadRadius: 0.0, // 在应用模糊之前,框应该膨胀的量。
),
],
),
child: LayoutBuilder(
builder: (context, BoxConstraints box) {
return NetworkImgLayer(
src: item!.cover,
width: box.maxWidth,
height: box.maxHeight,
);
},
),
),
Text(
' ${item!.title}',
overflow: TextOverflow.fade,
maxLines: 1,
),
Text(
'${item!.mediaCount}条视频',
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(color: Theme.of(context).colorScheme.outline),
)
],
),
),
);
}
}

View File

@ -0,0 +1,72 @@
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/models/user/stat.dart';
import 'package:pilipala/pages/main/controller.dart';
import 'package:pilipala/utils/storage.dart';
class MineController extends GetxController {
// 用户信息 头像、昵称、lv
Rx<UserInfoData> userInfo = UserInfoData().obs;
// 用户状态 动态、关注、粉丝
Rx<UserStat> userStat = UserStat().obs;
Box user = GStrorage.user;
RxBool userLogin = false.obs;
onLogin() {
Get.toNamed(
'/webview',
parameters: {
'url': 'https://passport.bilibili.com/h5-app/passport/login',
'type': 'login',
'pageTitle': '登录bilibili',
},
);
}
Future queryUserInfo() async {
if (user.get(UserBoxKey.userLogin) == null) {
return {'status': false};
}
var res = await UserHttp.userInfo();
if (res['status']) {
if (res['data'].isLogin) {
userInfo.value = res['data'];
user.put(UserBoxKey.userName, res['data'].uname);
user.put(UserBoxKey.userFace, res['data'].face);
user.put(UserBoxKey.userMid, res['data'].mid);
user.put(UserBoxKey.userLogin, true);
userLogin.value = true;
// Get.find<MainController>().readuUserFace();
} else {
resetUserInfo();
}
} else {
resetUserInfo();
// SmartDialog.showToast(res['msg']);
}
await queryUserStatOwner();
return res;
}
Future queryUserStatOwner() async {
var res = await UserHttp.userStatOwner();
if (res['status']) {
userStat.value = res['data'];
}
return res;
}
Future resetUserInfo() async {
userInfo.value = UserInfoData();
userStat.value = UserStat();
await user.delete(UserBoxKey.userName);
await user.delete(UserBoxKey.userFace);
await user.delete(UserBoxKey.userMid);
await user.delete(UserBoxKey.userLogin);
userLogin.value = false;
// Get.find<MainController>().resetLast();
}
}

View File

@ -1,4 +1,9 @@
import 'package:flutter/cupertino.dart';
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 'controller.dart';
class MinePage extends StatefulWidget {
const MinePage({super.key});
@ -8,11 +13,352 @@ class MinePage extends StatefulWidget {
}
class _MinePageState extends State<MinePage> {
final MineController _mineController = Get.put(MineController());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的'),
automaticallyImplyLeading: false,
scrolledUnderElevation: 0,
elevation: 0,
toolbarHeight: kTextTabBarHeight + 20,
backgroundColor: Colors.transparent,
title: null,
actions: [
IconButton(
onPressed: () {
Get.changeThemeMode(ThemeMode.dark);
},
icon: Icon(
Get.theme == ThemeData.light()
? CupertinoIcons.moon
: CupertinoIcons.sun_max,
size: 22,
),
),
IconButton(
onPressed: () => Get.toNamed('/setting'),
icon: const Icon(
CupertinoIcons.slider_horizontal_3,
),
),
const SizedBox(width: 10),
],
),
body: RefreshIndicator(
onRefresh: () async {
await _mineController.queryUserInfo();
await _mineController.queryUserStatOwner();
},
child: LayoutBuilder(
builder: (context, constraint) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: constraint.maxHeight,
child: Column(
children: [
const SizedBox(height: 10),
FutureBuilder(
future: _mineController.queryUserInfo(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
print(snapshot.data);
if (snapshot.data['status']) {
return Obx(() => userInfoBuild());
} else {
return userInfoBuild();
}
} else {
return userInfoBuild();
}
},
),
const SizedBox(height: 20),
],
),
),
);
},
),
),
);
}
Widget userInfoBuild() {
return Column(
children: [
const SizedBox(height: 5),
GestureDetector(
onTap: () => _mineController.onLogin(),
child: ClipOval(
child: Container(
width: 85,
height: 85,
color: Theme.of(context).colorScheme.onInverseSurface,
child: Center(
child: _mineController.userInfo.value.face != null
? NetworkImgLayer(
src: _mineController.userInfo.value.face,
width: 85,
height: 85)
: Image.asset('assets/images/loading.png'),
),
),
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_mineController.userInfo.value.uname ?? '点击头像登录',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(width: 4),
Image.asset(
'assets/images/lv/lv${_mineController.userInfo.value.levelInfo != null ? _mineController.userInfo.value.levelInfo!.currentLevel : '0'}.png',
height: 10,
),
],
),
const SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text.rich(TextSpan(children: [
TextSpan(
text: '硬币: ',
style:
TextStyle(color: Theme.of(context).colorScheme.outline)),
TextSpan(
text: (_mineController.userInfo.value.money ?? 'pilipala')
.toString(),
style:
TextStyle(color: Theme.of(context).colorScheme.primary)),
]))
],
),
const SizedBox(height: 5),
if (_mineController.userInfo.value.levelInfo != null) ...[
LayoutBuilder(
builder: (context, BoxConstraints box) {
return SizedBox(
width: box.maxWidth,
height: 24,
child: Stack(
children: [
Positioned(
top: 0,
right: 0,
child: SizedBox(
height: 22,
width: box.maxWidth *
(1 -
(_mineController
.userInfo.value.levelInfo!.currentExp! /
_mineController
.userInfo.value.levelInfo!.nextExp!)),
child: Center(
child: Text(
(_mineController
.userInfo.value.levelInfo!.nextExp! -
_mineController
.userInfo.value.levelInfo!.currentExp!)
.toString(),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
),
),
),
],
),
);
},
),
LayoutBuilder(
builder: (context, BoxConstraints box) {
return Container(
width: box.maxWidth,
height: 1,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Theme.of(context).colorScheme.onInverseSurface,
),
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
bottom: 0,
child: Container(
width: box.maxWidth *
(_mineController
.userInfo.value.levelInfo!.currentExp! /
_mineController
.userInfo.value.levelInfo!.nextExp!),
height: 1,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
);
},
),
],
const SizedBox(height: 30),
Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: LayoutBuilder(
builder: (context, constraints) {
TextStyle style = TextStyle(
fontSize: Theme.of(context).textTheme.titleMedium!.fontSize,
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold);
return SizedBox(
height: constraints.maxWidth / 3 * 0.6,
child: GridView.count(
primary: false,
padding: const EdgeInsets.all(0),
crossAxisCount: 3,
childAspectRatio: 1.67,
children: <Widget>[
InkWell(
onTap: () {},
borderRadius: StyleString.mdRadius,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(
scale: animation, child: child);
},
child: Text(
(_mineController.userStat.value.dynamicCount ??
'-')
.toString(),
key: ValueKey<String>(_mineController
.userStat.value.dynamicCount
.toString()),
style: style),
),
const SizedBox(height: 8),
Text(
'动态',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
InkWell(
onTap: () {},
borderRadius: StyleString.mdRadius,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(
scale: animation, child: child);
},
child: Text(
(_mineController.userStat.value.following ??
'-')
.toString(),
key: ValueKey<String>(_mineController
.userStat.value.following
.toString()),
style: style),
),
const SizedBox(height: 8),
Text(
'关注',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
InkWell(
onTap: () {},
borderRadius: StyleString.mdRadius,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(
scale: animation, child: child);
},
child: Text(
(_mineController.userStat.value.follower ?? '-')
.toString(),
key: ValueKey<String>(_mineController
.userStat.value.follower
.toString()),
style: style),
),
const SizedBox(height: 8),
Text(
'粉丝',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
],
),
);
},
),
),
],
);
}
}
class ActionItem extends StatelessWidget {
Icon? icon;
Function? onTap;
String? text;
ActionItem({
Key? key,
this.icon,
this.onTap,
this.text,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {},
borderRadius: StyleString.mdRadius,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon!.icon!),
const SizedBox(height: 8),
Text(
text!,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
);
}

View File

@ -0,0 +1,72 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:get/get.dart';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:pilipala/utils/utils.dart';
import 'package:share_plus/share_plus.dart';
class PreviewController extends GetxController {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
RxInt initialPage = 0.obs;
RxInt currentPage = 1.obs;
RxList imgList = [].obs;
bool storage = true;
bool videos = true;
bool photos = true;
bool visiable = true;
@override
void onInit() {
super.onInit();
if (Get.arguments != null) {
initialPage.value = Get.arguments['initialPage']!;
currentPage.value = Get.arguments['initialPage']! + 1;
imgList.value = Get.arguments['imgList'];
}
}
requestPermission() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.storage,
// Permission.photos
].request();
final info = statuses[Permission.storage].toString();
// final photosInfo = statuses[Permission.photos].toString();
print('授权状态:$info');
}
// 图片保存
void onSaveImg() async {
var response = await Dio().get(imgList[initialPage.value],
options: Options(responseType: ResponseType.bytes));
final result = await ImageGallerySaver.saveImage(
Uint8List.fromList(response.data),
quality: 100,
name: "pic_vvex${DateTime.now().toString().split('-').join()}");
if (result != null) {
if (result['isSuccess']) {
print('已保存到相册');
}
}
}
// 图片分享
void onShareImg() async {
requestPermission();
var response = await Dio().get(imgList[initialPage.value],
options: Options(responseType: ResponseType.bytes));
final temp = await getTemporaryDirectory();
String imgName =
"pic_vvex${DateTime.now().toString().split('-').join()}.jpg";
var path = '${temp.path}/$imgName';
File(path).writeAsBytesSync(response.data);
Share.shareXFiles([XFile(path)], subject: imgList[initialPage.value]);
}
}

View File

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

183
lib/pages/preview/view.dart Normal file
View File

@ -0,0 +1,183 @@
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:extended_image/extended_image.dart';
import 'package:pilipala/common/widgets/appbar.dart';
import 'controller.dart';
typedef DoubleClickAnimationListener = void Function();
class ImagePreview extends StatefulWidget {
const ImagePreview({Key? key}) : super(key: key);
@override
_ImagePreviewState createState() => _ImagePreviewState();
}
class _ImagePreviewState extends State<ImagePreview>
with TickerProviderStateMixin {
final PreviewController _previewController = Get.put(PreviewController());
late AnimationController animationController;
late AnimationController _doubleClickAnimationController;
Animation<double>? _doubleClickAnimation;
late DoubleClickAnimationListener _doubleClickAnimationListener;
List<double> doubleTapScales = <double>[1.0, 2.0];
@override
void initState() {
super.initState();
animationController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 400));
_doubleClickAnimationController = AnimationController(
duration: const Duration(milliseconds: 250), vsync: this);
}
@override
void dispose() {
animationController.dispose();
_doubleClickAnimationController.dispose();
clearGestureDetailsCache();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBarWidget(
controller: animationController,
visible: _previewController.visiable,
child: AppBar(
backgroundColor: Theme.of(context).colorScheme.background,
elevation: 0,
centerTitle: false,
title: Obx(
() => Text.rich(
TextSpan(children: [
TextSpan(text: _previewController.currentPage.toString()),
const TextSpan(text: ' / '),
TextSpan(text: _previewController.imgList.length.toString()),
]),
),
),
actions: [
PopupMenuButton(
icon: const Icon(Icons.more_vert),
tooltip: 'action',
itemBuilder: (BuildContext context) => <PopupMenuEntry>[
PopupMenuItem(
value: 'share',
onTap: _previewController.onShareImg,
child: const Text('分享'),
),
PopupMenuItem(
value: 'save',
onTap: _previewController.onSaveImg,
child: const Text('保存'),
),
],
),
],
),
),
body: GestureDetector(
onTap: () {
_previewController.visiable = !_previewController.visiable;
setState(() {});
},
child: ExtendedImageGesturePageView.builder(
controller: ExtendedPageController(
initialPage: _previewController.initialPage.value,
pageSpacing: 0,
),
onPageChanged: (int index) {
_previewController.initialPage.value = index;
_previewController.currentPage.value = index + 1;
},
canScrollPage: (GestureDetails? gestureDetails) =>
gestureDetails!.totalScale! <= 1.0,
preloadPagesCount: 2,
itemCount: _previewController.imgList.length,
itemBuilder: (BuildContext context, int index) {
return ExtendedImage.network(
_previewController.imgList[index],
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
onDoubleTap: (ExtendedImageGestureState state) {
final Offset? pointerDownPosition = state.pointerDownPosition;
final double? begin = state.gestureDetails!.totalScale;
double end;
//remove old
_doubleClickAnimation
?.removeListener(_doubleClickAnimationListener);
//stop pre
_doubleClickAnimationController.stop();
//reset to use
_doubleClickAnimationController.reset();
if (begin == doubleTapScales[0]) {
end = doubleTapScales[1];
} else {
end = doubleTapScales[0];
}
_doubleClickAnimationListener = () {
state.handleDoubleTap(
scale: _doubleClickAnimation!.value,
doubleTapPosition: pointerDownPosition);
};
_doubleClickAnimation = _doubleClickAnimationController
.drive(Tween<double>(begin: begin, end: end));
_doubleClickAnimation!
.addListener(_doubleClickAnimationListener);
_doubleClickAnimationController.forward();
},
loadStateChanged: (ExtendedImageState state) {
if (state.extendedImageLoadState == LoadState.loading) {
final ImageChunkEvent? loadingProgress =
state.loadingProgress;
final double? progress =
loadingProgress?.expectedTotalBytes != null
? loadingProgress!.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 150.0,
child: LinearProgressIndicator(value: progress),
),
const SizedBox(height: 10.0),
Text('${((progress ?? 0.0) * 100).toInt()}%'),
],
),
);
}
},
initGestureConfigHandler: (ExtendedImageState state) {
return GestureConfig(
inPageView: true,
initialScale: 1.0,
maxScale: 5.0,
animationMaxScale: 6.0,
initialAlignment: InitialAlignment.center,
);
},
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _previewController.onSaveImg(),
child: const Icon(Icons.save_alt_rounded),
),
);
}
}

View File

@ -0,0 +1,23 @@
import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:pilipala/http/init.dart';
import 'package:pilipala/pages/mine/controller.dart';
import 'package:pilipala/utils/storage.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class SettingController extends GetxController {
Box user = GStrorage.user;
RxBool userLogin = false.obs;
@override
void onInit() {
super.onInit();
userLogin.value = user.get(UserBoxKey.userLogin) ?? false;
}
loginOut() async {
await Request.removeCookie();
await Get.find<MineController>().resetUserInfo();
userLogin.value = user.get(UserBoxKey.userLogin) ?? false;
}
}

View File

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

View File

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/pages/setting/index.dart';
class SettingPage extends StatefulWidget {
const SettingPage({super.key});
@override
State<SettingPage> createState() => _SettingPageState();
}
class _SettingPageState extends State<SettingPage> {
final SettingController _settingController = Get.put(SettingController());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('设置'),
),
body: Column(
children: [
Obx(
() => Visibility(
visible: _settingController.userLogin.value,
child: ListTile(
onTap: () => _settingController.loginOut(),
dense: false,
title: const Text('退出登录'),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,36 @@
import 'package:get/get.dart';
class VideoDetailController extends GetxController {
int tabInitialIndex = 0;
// tabs
RxList<String> tabs = <String>['简介', '评论'].obs;
// 视频aid
String aid = Get.parameters['aid']!;
// 是否预渲染 骨架屏
bool preRender = false;
// 视频详情 上个页面传入
Map videoItem = {};
// 请求状态
RxBool isLoading = false.obs;
String heroTag = '';
@override
void onInit() {
super.onInit();
if (Get.arguments.isNotEmpty) {
if (Get.arguments.containsKey('videoItem')) {
preRender = true;
var args = Get.arguments['videoItem'];
if (args.pic != null && args.pic != '') {
videoItem['pic'] = args.pic;
}
}
heroTag = Get.arguments['heroTag'];
}
}
}

View File

@ -0,0 +1,231 @@
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/http/video.dart';
import 'package:pilipala/models/user/fav_folder.dart';
import 'package:pilipala/models/video_detail_res.dart';
import 'package:pilipala/pages/video/detail/controller.dart';
import 'package:pilipala/utils/storage.dart';
class VideoIntroController extends GetxController {
// 视频aid
String aid = Get.parameters['aid']!;
// 是否预渲染 骨架屏
bool preRender = false;
// 视频详情 上个页面传入
Map? videoItem = {};
// 请求状态
RxBool isLoading = false.obs;
// 视频详情 请求返回
Rx<VideoDetailData> videoDetail = VideoDetailData().obs;
// 请求返回的信息
String responseMsg = '请求异常';
// up主粉丝数
Map userStat = {'follower': '-'};
// 是否点赞
RxBool hasLike = false.obs;
// 是否投币
RxBool hasCoin = false.obs;
// 是否收藏
RxBool hasFav = false.obs;
Box user = GStrorage.user;
bool userLogin = false;
Rx<FavFolderData> favFolderData = FavFolderData().obs;
List addMediaIdsNew = [];
List delMediaIdsNew = [];
@override
void onInit() {
super.onInit();
if (Get.arguments.isNotEmpty) {
if (Get.arguments.containsKey('videoItem')) {
preRender = true;
var args = Get.arguments['videoItem'];
videoItem!['pic'] = args.pic;
videoItem!['title'] = args.title;
if (args.stat != null) {
videoItem!['stat'] = args.stat;
}
videoItem!['pubdate'] = args.pubdate;
videoItem!['owner'] = args.owner;
}
}
userLogin = user.get(UserBoxKey.userLogin) != null;
}
// 获取视频简介
Future queryVideoIntro() async {
var result = await VideoHttp.videoIntro(aid: aid);
if (result['status']) {
videoDetail.value = result['data']!;
Get.find<VideoDetailController>(tag: Get.arguments['heroTag'])
.tabs
.value = ['简介', '评论 ${result['data']!.stat!.reply}'];
} else {
responseMsg = result['msg'];
}
// 获取到粉丝数再返回
await queryUserStat();
if (userLogin) {
// 获取点赞状态
queryHasLikeVideo();
// 获取投币状态
queryHasCoinVideo();
// 获取收藏状态
queryHasFavVideo();
}
return result;
}
// 获取up主粉丝数
Future queryUserStat() async {
var result = await UserHttp.userStat(mid: videoDetail.value.owner!.mid!);
if (result['status']) {
userStat = result['data'];
}
}
// 获取点赞状态
Future queryHasLikeVideo() async {
var result = await VideoHttp.hasLikeVideo(aid: aid);
// data num 被点赞标志 0未点赞 1已点赞
hasLike.value = result["data"] == 1 ? true : false;
}
// 获取投币状态
Future queryHasCoinVideo() async {
var result = await VideoHttp.hasCoinVideo(aid: aid);
hasCoin.value = result["data"]['multiply'] == 0 ? false : true;
}
// 获取收藏状态
Future queryHasFavVideo() async {
var result = await VideoHttp.hasFavVideo(aid: aid);
hasFav.value = result["data"]['favoured'];
}
// 一键三连
Future actionOneThree() async {
if (hasLike.value && hasCoin.value && hasFav.value) {
// 已点赞、投币、收藏
SmartDialog.showToast('🙏 UP已经收到了');
return false;
}
SmartDialog.show(
useSystem: true,
animationType: SmartAnimationType.centerFade_otherSlide,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('提示'),
content: const Text('一键三连 给UP送温暖'),
actions: [
TextButton(
onPressed: () => SmartDialog.dismiss(),
child: const Text('点错了')),
TextButton(
onPressed: () async {
var result = await VideoHttp.oneThree(aid: aid);
if (result['status']) {
hasLike.value = result["data"]["like"];
hasCoin.value = result["data"]["coin"];
hasFav.value = result["data"]["fav"];
SmartDialog.showToast('三连成功 🎉');
} else {
SmartDialog.showToast(result['msg']);
}
SmartDialog.dismiss();
},
child: const Text('确认'),
)
],
);
},
);
}
// (取消)点赞
Future actionLikeVideo() async {
var result = await VideoHttp.likeVideo(aid: aid, type: !hasLike.value);
if (result['status']) {
hasLike.value = result["data"] == 1 ? true : false;
if (hasLike.value) {
SmartDialog.showToast('已点赞 👍');
} else {
SmartDialog.showToast('取消赞');
}
} else {
SmartDialog.showToast(result['msg']);
}
}
// 投币
Future actionCoinVideo() async {
print('投币');
}
// (取消)收藏
Future actionFavVideo() async {
try {
for (var i in favFolderData.value.list!.toList()) {
if (i.favState == 1) {
addMediaIdsNew.add(i.id);
} else {
delMediaIdsNew.add(i.id);
}
}
} catch (e) {}
var result = await VideoHttp.favVideo(
aid: aid,
addIds: addMediaIdsNew.join(','),
delIds: delMediaIdsNew.join(','));
if (result['status']) {
if (result['data']['prompt']) {
addMediaIdsNew = [];
delMediaIdsNew = [];
Get.back();
// 重新获取收藏状态
queryHasFavVideo();
SmartDialog.showToast('✅ 操作成功');
}
}
}
// 分享视频
Future actionShareVideo() async {
print('分享视频');
}
Future queryVideoInFolder() async {
var result = await VideoHttp.videoInFolder(
mid: user.get(UserBoxKey.userMid), rid: aid);
if (result['status']) {
favFolderData.value = result['data'];
}
return result;
}
// 选择文件夹
onChoose(bool checkValue, int index) {
List<FavFolderItemData> datalist = favFolderData.value.list!;
for (var i = 0; i < datalist.length; i++) {
if (i == index) {
datalist[i].favState = checkValue == true ? 1 : 0;
datalist[i].mediaCount = checkValue == true
? datalist[i].mediaCount! + 1
: datalist[i].mediaCount! - 1;
}
}
favFolderData.value.list = datalist;
favFolderData.refresh();
}
}

View File

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

View File

@ -0,0 +1,541 @@
import 'package:flutter/cupertino.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/common/constants.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/pages/fav/index.dart';
import 'package:pilipala/pages/favDetail/index.dart';
import 'package:pilipala/pages/video/detail/widgets/expandable_section.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/common/widgets/stat/danmu.dart';
import 'package:pilipala/common/widgets/stat/view.dart';
import 'package:pilipala/models/video_detail_res.dart';
import 'package:pilipala/pages/video/detail/introduction/controller.dart';
import 'package:pilipala/utils/utils.dart';
class VideoIntroPanel extends StatefulWidget {
const VideoIntroPanel({Key? key}) : super(key: key);
@override
State<VideoIntroPanel> createState() => _VideoIntroPanelState();
}
class _VideoIntroPanelState extends State<VideoIntroPanel>
with AutomaticKeepAliveClientMixin {
final VideoIntroController videoIntroController =
Get.put(VideoIntroController(), tag: Get.arguments['heroTag']);
VideoDetailData? videoDetail;
// 添加页面缓存
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
videoIntroController.videoDetail.listen((value) {
videoDetail = value;
});
}
@override
void dispose() {
videoIntroController.onClose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: videoIntroController.queryVideoIntro(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.data['status']) {
// 请求成功
// return _buildView(context, false, videoDetail);
return VideoInfo(loadingStatus: false, videoDetail: videoDetail);
} else {
// 请求错误
return HttpError(
errMsg: snapshot.data['msg'],
fn: () => setState(() {}),
);
}
} else {
return VideoInfo(loadingStatus: true, videoDetail: videoDetail);
}
},
);
}
}
class VideoInfo extends StatefulWidget {
bool loadingStatus = false;
VideoDetailData? videoDetail;
VideoInfo({Key? key, required this.loadingStatus, this.videoDetail})
: super(key: key);
@override
State<VideoInfo> createState() => _VideoInfoState();
}
class _VideoInfoState extends State<VideoInfo> with TickerProviderStateMixin {
Map videoItem = Get.put(VideoIntroController()).videoItem!;
final VideoIntroController videoIntroController =
Get.put(VideoIntroController(), tag: Get.arguments['heroTag']);
bool isExpand = false;
/// 手动控制动画的控制器
late AnimationController? _manualController;
/// 手动控制
late Animation<double>? _manualAnimation;
final FavController _favController = Get.put(FavController());
@override
void initState() {
super.initState();
/// 不设置重复使用代码控制进度动画时间1秒
_manualController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_manualAnimation =
Tween<double>(begin: 0.5, end: 1.5).animate(_manualController!);
}
showFavBottomSheet() {
Get.bottomSheet(
useRootNavigator: true,
isScrollControlled: true,
Container(
height: 450,
color: Theme.of(context).colorScheme.background,
child: Column(
children: [
AppBar(
toolbarHeight: 50,
automaticallyImplyLeading: false,
centerTitle: false,
elevation: 1,
title: Text(
'选择文件夹',
style: Theme.of(context).textTheme.titleMedium,
),
actions: [
TextButton(
onPressed: () => videoIntroController.actionFavVideo(),
child: const Text('完成'),
),
const SizedBox(width: 6),
],
),
Expanded(
child: Material(
child: FutureBuilder(
future: videoIntroController.queryVideoInFolder(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data as Map;
if (data['status']) {
return Obx(
() => ListView.builder(
itemCount: videoIntroController
.favFolderData.value.list!.length +
1,
itemBuilder: (context, index) {
if (index == 0) {
return const SizedBox(height: 10);
} else {
return ListTile(
onTap: () => videoIntroController.onChoose(
videoIntroController.favFolderData.value
.list![index - 1].favState !=
1,
index - 1),
dense: true,
leading:
const Icon(Icons.folder_special_outlined),
minLeadingWidth: 0,
title: Text(videoIntroController.favFolderData
.value.list![index - 1].title!),
subtitle: Text(
'${videoIntroController.favFolderData.value.list![index - 1].mediaCount}个内容',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.outline,
fontSize: Theme.of(context)
.textTheme
.labelSmall!
.fontSize),
),
trailing: Transform.scale(
scale: 0.9,
child: Checkbox(
value: videoIntroController
.favFolderData
.value
.list![index - 1]
.favState ==
1,
onChanged: (bool? checkValue) =>
videoIntroController.onChoose(
checkValue!, index - 1),
),
),
);
}
},
),
);
} else {
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return Text('请求中');
}
},
),
),
),
],
),
),
persistent: false,
backgroundColor: Theme.of(context).bottomSheetTheme.backgroundColor,
);
}
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.only(left: 12, right: 12, top: 20),
sliver: SliverToBoxAdapter(
child: !widget.loadingStatus || videoItem.isNotEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableRegion(
magnifierConfiguration: const TextMagnifierConfiguration(),
focusNode: FocusNode(),
selectionControls: MaterialTextSelectionControls(),
child: Text(
!widget.loadingStatus
? widget.videoDetail!.title
: videoItem['title'],
style: Theme.of(context).textTheme.titleMedium!.copyWith(
letterSpacing: 0.5,
),
),
),
InkWell(
splashColor: Colors.transparent,
hoverColor: Colors.transparent,
highlightColor: Colors.transparent,
onTap: () {
_manualController!.animateTo(isExpand ? 0 : 0.5);
setState(() {
isExpand = !isExpand;
});
},
child: Row(
children: [
const SizedBox(width: 2),
StatView(
theme: 'gray',
view: !widget.loadingStatus
? widget.videoDetail!.stat!.view
: videoItem['stat'].view,
size: 'medium',
),
const SizedBox(width: 10),
StatDanMu(
theme: 'gray',
danmu: !widget.loadingStatus
? widget.videoDetail!.stat!.danmaku
: videoItem['stat'].danmaku,
size: 'medium',
),
const SizedBox(width: 10),
Text(
Utils.dateFormat(
!widget.loadingStatus
? widget.videoDetail!.pubdate
: videoItem['pubdate'],
formatType: 'detail'),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.outline),
),
const Spacer(),
RotationTransition(
turns: _manualAnimation!,
child: SizedBox(
width: 35,
height: 35,
child: IconButton(
padding: const EdgeInsets.all(2.0),
onPressed: () {
/// 0.5代表 180弧度
_manualController!
.animateTo(isExpand ? 0 : 0.5);
setState(() {
isExpand = !isExpand;
});
},
icon: Icon(
FontAwesomeIcons.angleUp,
size: 15,
color: Theme.of(context).colorScheme.outline,
),
),
),
),
const SizedBox(width: 10),
],
),
),
// 简介 默认收起
if (!widget.loadingStatus)
ExpandedSection(
expand: isExpand,
begin: 0.0,
end: 1.0,
child: DefaultTextStyle(
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
height: 1.5,
fontSize:
Theme.of(context).textTheme.labelMedium?.fontSize,
),
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SelectableRegion(
magnifierConfiguration:
const TextMagnifierConfiguration(),
focusNode: FocusNode(),
selectionControls: MaterialTextSelectionControls(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.videoDetail!.bvid!),
Text(widget.videoDetail!.desc!),
],
),
),
),
),
),
const SizedBox(height: 8),
_actionGrid(context, videoIntroController),
Divider(
height: 26,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
Row(
children: [
NetworkImgLayer(
type: 'avatar',
src: !widget.loadingStatus
? widget.videoDetail!.owner!.face
: videoItem['owner'].face,
width: 38,
height: 38,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(!widget.loadingStatus
? widget.videoDetail!.owner!.name
: videoItem['owner'].name),
// const SizedBox(width: 10),
Text(
widget.loadingStatus
? '- 粉丝'
: '${Utils.numFormat(videoIntroController.userStat['follower'])}粉丝',
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.labelSmall!
.fontSize,
color: Theme.of(context).colorScheme.outline),
),
],
),
const Spacer(),
AnimatedOpacity(
opacity: widget.loadingStatus ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: SizedBox(
height: 36,
child: ElevatedButton(
onPressed: () {},
child: const Text('关注'),
),
),
),
],
),
Divider(
height: 26,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
// const SizedBox(height: 10),
],
)
: const Center(child: CircularProgressIndicator()),
),
);
}
// 喜欢 投币 分享
Widget _actionGrid(BuildContext context, videoIntroController) {
return LayoutBuilder(builder: (context, constraints) {
return SizedBox(
height: constraints.maxWidth / 5 * 0.8,
child: GridView.count(
primary: false,
padding: const EdgeInsets.all(0),
crossAxisCount: 5,
childAspectRatio: 1.25,
children: <Widget>[
// ActionItem(
// icon: const Icon(FontAwesomeIcons.s),
// selectIcon: const Icon(FontAwesomeIcons.s),
// onTap: () => {},
// selectStatus: true,
// loadingStatus: false,
// text: '三连',
// ),
// Column(
// children: [],
// ),
InkWell(
onTap: () => videoIntroController.actionOneThree(),
borderRadius: StyleString.mdRadius,
child: Padding(
padding: const EdgeInsets.all(12),
child: Image.asset(
'assets/images/logo/logo_big.png',
width: 10,
height: 10,
),
),
),
Obx(
() => ActionItem(
icon: const Icon(FontAwesomeIcons.thumbsUp),
selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp),
onTap: () => videoIntroController.actionLikeVideo(),
selectStatus: videoIntroController.hasLike.value,
loadingStatus: widget.loadingStatus,
text: !widget.loadingStatus
? widget.videoDetail!.stat!.like!.toString()
: '-'),
),
// ActionItem(
// icon: const Icon(FontAwesomeIcons.thumbsDown),
// selectIcon: const Icon(FontAwesomeIcons.solidThumbsDown),
// onTap: () => {},
// selectStatus: false,
// loadingStatus: widget.loadingStatus,
// text: '不喜欢'),
Obx(
() => ActionItem(
icon: const Icon(FontAwesomeIcons.b),
selectIcon: const Icon(FontAwesomeIcons.b),
onTap: () => videoIntroController.actionCoinVideo(),
selectStatus: videoIntroController.hasCoin.value,
loadingStatus: widget.loadingStatus,
text: !widget.loadingStatus
? widget.videoDetail!.stat!.coin!.toString()
: '-'),
),
Obx(
() => ActionItem(
icon: const Icon(FontAwesomeIcons.star),
selectIcon: const Icon(FontAwesomeIcons.star),
onTap: () => showFavBottomSheet(),
selectStatus: videoIntroController.hasFav.value,
loadingStatus: widget.loadingStatus,
text: !widget.loadingStatus
? widget.videoDetail!.stat!.favorite!.toString()
: '-'),
),
ActionItem(
icon: const Icon(FontAwesomeIcons.shareFromSquare),
onTap: () => videoIntroController.actionShareVideo(),
selectStatus: false,
loadingStatus: widget.loadingStatus,
text: !widget.loadingStatus
? widget.videoDetail!.stat!.share!.toString()
: '-'),
],
),
);
});
}
}
class ActionItem extends StatelessWidget {
Icon? icon;
Icon? selectIcon;
Function? onTap;
bool? loadingStatus;
String? text;
bool selectStatus = false;
ActionItem({
Key? key,
this.icon,
this.selectIcon,
this.onTap,
this.loadingStatus,
this.text,
required this.selectStatus,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => onTap!(),
borderRadius: StyleString.mdRadius,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 4),
selectStatus
? Icon(selectIcon!.icon!,
size: 21, color: Theme.of(context).primaryColor)
: Icon(icon!.icon!,
size: 21, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 4),
AnimatedOpacity(
opacity: loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: Text(
text ?? '',
style: TextStyle(
color: selectStatus
? Theme.of(context).primaryColor
: Theme.of(context).colorScheme.outline,
fontSize: Theme.of(context).textTheme.labelSmall?.fontSize),
),
),
],
),
);
}
}

View File

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

View File

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import 'package:pilipala/http/video.dart';
class ReleatedController extends GetxController {
// 视频aid
String aid = Get.parameters['aid']!;
// 推荐视频列表
List relatedVideoList = [];
Future<dynamic> queryRelatedVideo() => VideoHttp.relatedVideoList(aid: aid);
}

View File

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

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/skeleton/video_card_h.dart';
import 'package:pilipala/common/widgets/video_card_h.dart';
import 'package:pilipala/common/widgets/video_card_v.dart';
import './controller.dart';
class RelatedVideoPanel extends StatefulWidget {
const RelatedVideoPanel({super.key});
@override
State<RelatedVideoPanel> createState() => _RelatedVideoPanelState();
}
class _RelatedVideoPanelState extends State<RelatedVideoPanel> {
final ReleatedController _releatedController =
Get.put(ReleatedController(), tag: Get.arguments['heroTag']);
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _releatedController.queryRelatedVideo(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.data!['status']) {
// 请求成功
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == snapshot.data['data'].length) {
return SizedBox(height: MediaQuery.of(context).padding.bottom);
} else {
return VideoCardH(
videoItem: snapshot.data['data'][index],
);
}
}, childCount: snapshot.data['data'].length + 1));
} else {
// 请求错误
return const Center(
child: Text('出错了'),
);
}
} else {
// 骨架屏
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return const VideoCardHSkeleton();
}, childCount: 5),
);
}
},
);
}
}

View File

@ -0,0 +1,117 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/reply.dart';
import 'package:pilipala/http/video.dart';
import 'package:pilipala/models/common/reply_type.dart';
import 'package:pilipala/models/video/reply/data.dart';
import 'package:pilipala/models/video/reply/item.dart';
class VideoReplyController extends GetxController {
VideoReplyController(
this.aid,
this.rpid,
this.level
);
final ScrollController scrollController = ScrollController();
// 视频aid 请求时使用的oid
String? aid;
// 层级 2为楼中楼
String? level;
// rpid 请求楼中楼回复
String? rpid;
RxList<ReplyItemModel> replyList = [ReplyItemModel()].obs;
// 当前页
int currentPage = 0;
bool isLoadingMore = false;
RxBool noMore = false.obs;
RxBool autoFocus = false.obs;
// 当前回复的回复
ReplyItemModel? currentReplyItem;
// 回复来源
String replySource = 'main';
// 根评论 id 回复楼中楼回复使用
int? rPid;
// 默认回复主楼
String replyLevel = '0';
Future queryReplyList({type = 'init'}) async {
isLoadingMore = true;
var res = level == '1'
? await ReplyHttp.replyList(
oid: aid!, pageNum: currentPage + 1, type: 1)
: await ReplyHttp.replyReplyList(
oid: aid!, root: rpid!, pageNum: currentPage + 1, type: 1);
if (res['status']) {
res['data'] = ReplyData.fromJson(res['data']);
if (res['data'].replies.isNotEmpty) {
currentPage = currentPage + 1;
noMore.value = false;
} else {
if (currentPage == 0) {
} else {
noMore.value = true;
return;
}
}
if (res['data'].replies.length >= res['data'].page.count) {
noMore.value = true;
}
if (type == 'init') {
List<ReplyItemModel> replies = res['data'].replies;
// 添加置顶回复
if (res['data'].upper.top != null) {
bool flag = false;
for (var i = 0; i < res['data'].topReplies.length; i++) {
if (res['data'].topReplies[i].rpid == res['data'].upper.top.rpid) {
flag = true;
}
}
if (!flag) {
replies.insert(0, res['data'].upper.top);
}
}
replies.insertAll(0, res['data'].topReplies);
res['data'].replies = replies;
replyList.value = res['data'].replies!;
} else {
replyList.addAll(res['data'].replies!);
res['data'].replies.addAll(replyList);
}
}
isLoadingMore = false;
return res;
}
// 上拉加载
Future onLoad() async {
queryReplyList(type: 'onLoad');
}
wakeUpReply() {
autoFocus.value = true;
}
// 发表评论
Future submitReplyAdd() async {
print('replyLevel: $replyLevel');
// print('rpid: $rpid');
// print('currentReplyItem!.rpid: ${currentReplyItem!.rpid}');
var result = await VideoHttp.replyAdd(
type: ReplyType.video,
oid: int.parse(aid!),
root: replyLevel == '0' ? 0 : replyLevel == '1' ? currentReplyItem!.rpid : rPid,
parent: replyLevel == '0' ? 0 : replyLevel == '1' ? currentReplyItem!.rpid : currentReplyItem!.rpid,
message: replyLevel == '2' ? ' 回复 @${currentReplyItem!.member!.uname!} : 2楼31' : '2楼31',
);
if(result['status']){
SmartDialog.showToast(result['data']['success_toast']);
}else{
SmartDialog.showToast(result['message']);
}
}
}

View File

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

View File

@ -0,0 +1,280 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/skeleton/video_card_h.dart';
import 'package:pilipala/common/skeleton/video_reply.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'controller.dart';
import 'widgets/reply_item.dart';
class VideoReplyPanel extends StatefulWidget {
int oid;
int rpid;
String? level;
VideoReplyPanel({
this.oid = 0,
this.rpid = 0,
this.level,
super.key,
});
@override
State<VideoReplyPanel> createState() => _VideoReplyPanelState();
}
class _VideoReplyPanelState extends State<VideoReplyPanel>
with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
late VideoReplyController _videoReplyController;
late AnimationController fabAnimationCtr;
late AnimationController replyAnimationCtl;
// List<ReplyItemModel>? replyList;
Future? _futureBuilderFuture;
bool _isFabVisible = true;
String replyLevel = '1';
// 添加页面缓存
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
replyLevel = widget.level ?? '1';
if (widget.level != null && widget.level == '2') {
_videoReplyController = Get.put(
VideoReplyController(
widget.oid.toString(), widget.rpid.toString(), '2'),
tag: widget.rpid.toString());
_videoReplyController.rPid = widget.rpid;
} else {
_videoReplyController = Get.put(
VideoReplyController(Get.parameters['aid']!, '', '1'),
tag: Get.arguments['heroTag']);
}
// if(replyLevel != ''){
// _videoReplyController.replyLevel = replyLevel;
// }
print(
'_videoReplyController.replyLevel: ${_videoReplyController.replyLevel}');
fabAnimationCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));
replyAnimationCtl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 500));
_futureBuilderFuture = _videoReplyController.queryReplyList();
_videoReplyController.scrollController.addListener(
() {
if (_videoReplyController.scrollController.position.pixels >=
_videoReplyController.scrollController.position.maxScrollExtent -
300) {
if (!_videoReplyController.isLoadingMore) {
_videoReplyController.onLoad();
}
}
final ScrollDirection direction =
_videoReplyController.scrollController.position.userScrollDirection;
if (direction == ScrollDirection.forward) {
_showFab();
} else if (direction == ScrollDirection.reverse) {
_hideFab();
}
},
);
}
void _showFab() {
if (!_isFabVisible) {
_isFabVisible = true;
fabAnimationCtr.forward();
}
}
void _hideFab() {
if (_isFabVisible) {
_isFabVisible = false;
fabAnimationCtr.reverse();
}
}
void _showReply(source, {ReplyItemModel? replyItem, replyLevel}) async {
// source main 直接回复 floor 楼中楼回复
if (source == 'floor') {
_videoReplyController.currentReplyItem = replyItem;
_videoReplyController.replySource = source;
_videoReplyController.replyLevel = replyLevel ?? '1';
} else {
_videoReplyController.replyLevel = '0';
}
replyAnimationCtl.forward();
await Future.delayed(const Duration(microseconds: 100));
_videoReplyController.wakeUpReply();
}
@override
void dispose() {
// TODO: implement dispose
super.dispose();
fabAnimationCtr.dispose();
_videoReplyController.scrollController.dispose();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async {
setState(() {});
_videoReplyController.currentPage = 0;
return await _videoReplyController.queryReplyList();
},
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
CustomScrollView(
controller: _videoReplyController.scrollController,
key: const PageStorageKey<String>('评论'),
slivers: <Widget>[
FutureBuilder(
future: _futureBuilderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data as Map;
if (data['status']) {
// 请求成功
return Obx(
() => SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index ==
_videoReplyController.replyList.length) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context)
.padding
.bottom),
height:
MediaQuery.of(context).padding.bottom +
60,
child: Center(
child: Obx(() => Text(
_videoReplyController.noMore.value
? '没有更多了'
: '加载中')),
),
);
} else {
return ReplyItem(
replyItem: _videoReplyController
.replyList[index],
weakUpReply: (replyItem, replyLevel) =>
_showReply(
'floor',
replyItem: replyItem,
replyLevel: replyLevel,
),
replyLevel: replyLevel);
}
},
childCount:
_videoReplyController.replyList.length + 1,
),
),
);
} else {
// 请求错误
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return const VideoReplySkeleton();
}, childCount: 5),
);
}
},
)
],
),
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 14,
right: 14,
child: SlideTransition(
position: Tween<Offset>(
// begin: const Offset(0, 2),
// 评论内容为空/不足一屏
begin: const Offset(0, 0),
end: const Offset(0, 0),
).animate(CurvedAnimation(
parent: fabAnimationCtr,
curve: Curves.easeInOut,
)),
child: FloatingActionButton(
heroTag: null,
onPressed: () => _showReply('main'),
tooltip: '发表评论',
child: const Icon(Icons.reply),
),
),
),
Obx(
() => Positioned(
bottom: 0,
left: 0,
right: 0,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 2),
end: const Offset(0, 0),
).animate(CurvedAnimation(
parent: replyAnimationCtl,
curve: Curves.easeInOut,
)),
child: Container(
height: 100 + MediaQuery.of(context).padding.bottom,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom),
color: Theme.of(context).colorScheme.surfaceVariant,
child: Padding(
padding: const EdgeInsets.only(left: 14, right: 14),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Visibility(
visible: _videoReplyController.autoFocus.value,
child: const TextField(
autofocus: true,
maxLines: null,
decoration: InputDecoration(
hintText: "友善评论", border: InputBorder.none),
),
),
TextButton(
onPressed: () =>
_videoReplyController.submitReplyAdd(),
child: const Text('发送'),
)
],
),
),
),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,689 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/video/detail/reply/index.dart';
import 'package:pilipala/utils/utils.dart';
class ReplyItem extends StatelessWidget {
ReplyItem({super.key, this.replyItem, this.weakUpReply, this.replyLevel});
ReplyItemModel? replyItem;
Function? weakUpReply;
String? replyLevel;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {},
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 8, 0),
child: content(context),
),
// Divider(
// height: 1,
// indent: 52,
// endIndent: 10,
// color: Theme.of(context).dividerColor.withOpacity(0.08),
// )
],
),
);
}
Widget lfAvtar(context) {
return Container(
margin: const EdgeInsets.only(top: 5),
child: Stack(
children: [
NetworkImgLayer(
src: replyItem!.member!.avatar,
width: 34,
height: 34,
type: 'avatar',
),
if (replyItem!.member!.officialVerify != null &&
replyItem!.member!.officialVerify!['type'] == 0)
Positioned(
right: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
color: Theme.of(context).colorScheme.background,
),
child: Icon(
Icons.offline_bolt,
color: Theme.of(context).colorScheme.primary,
size: 16,
),
),
),
],
)
// child:
// NetworkImgLayer(
// src: replyItem!.member!.avatar,
// width: 30,
// height: 30,
// type: 'avatar',
// ),
);
}
Widget content(context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 头像、昵称
GestureDetector(
// onTap: () =>
// Get.toNamed('/member/${reply.userName}', parameters: {
// 'memberAvatar': reply.avatar,
// 'heroTag': reply.userName + heroTag,
// }),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
image: replyItem!.member!.userSailing!.cardbg != null
? DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(
replyItem!.member!.userSailing!.cardbg!['image'],
),
)
: null,
),
child: Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
lfAvtar(context),
const SizedBox(width: 12),
Text(
replyItem!.member!.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.titleSmall!.fontSize,
),
),
const SizedBox(width: 6),
Image.asset(
'assets/images/lv/lv${replyItem!.member!.level}.png',
height: 11,
),
const SizedBox(width: 6),
if (replyItem!.isUp!) UpTag(),
],
),
if (replyItem!.member!.userSailing!.cardbg != null &&
replyItem!.member!.userSailing!.cardbg!['fan']['number'] >
0)
Positioned(
top: 8,
left: Get.size.width / 7 * 5.6,
child: DefaultTextStyle(
style: TextStyle(
fontFamily: 'fansCard',
fontSize: 9,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('NO.'),
Text(
replyItem!.member!.userSailing!.cardbg!['fan']
['num_desc'],
),
],
),
),
),
],
),
),
),
// title
Container(
margin: const EdgeInsets.only(top: 0, left: 45, right: 6, bottom: 6),
child: SelectableRegion(
magnifierConfiguration: const TextMagnifierConfiguration(),
focusNode: FocusNode(),
selectionControls: MaterialTextSelectionControls(),
child: Text.rich(
style: const TextStyle(height: 1.65),
TextSpan(
children: [
if (replyItem!.isTop!)
WidgetSpan(child: UpTag(tagText: 'TOP')),
buildContent(context, replyItem!.content!),
],
),
),
),
),
// 操作区域
bottonAction(context, replyItem!.replyControl),
const SizedBox(height: 3),
if (replyItem!.replies!.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12),
child: ReplyItemRow(
replies: replyItem!.replies,
replyControl: replyItem!.replyControl,
f_rpid: replyItem!.rpid,
),
),
],
],
);
}
// 感谢、回复、复制
Widget bottonAction(context, replyControl) {
var color = Theme.of(context).colorScheme.outline;
return Row(
children: [
const SizedBox(width: 48),
if (replyItem!.cardLabel!.isNotEmpty &&
replyItem!.cardLabel!.contains('热评'))
Text(
'热评 • ',
style: Theme.of(context)
.textTheme
.labelMedium!
.copyWith(color: Theme.of(context).colorScheme.primary),
),
Text(
Utils.dateFormat(replyItem!.ctime),
style: Theme.of(context)
.textTheme
.labelMedium!
.copyWith(color: Theme.of(context).colorScheme.outline),
),
if (replyItem!.replyControl != null &&
replyItem!.replyControl!.location != '')
Text(
'${replyItem!.replyControl!.location!}',
style: Theme.of(context)
.textTheme
.labelMedium!
.copyWith(color: Theme.of(context).colorScheme.outline),
),
const Spacer(),
if (replyItem!.upAction!.like!)
Icon(Icons.favorite, color: Colors.red[400], size: 18),
SizedBox(
height: 28,
width: 42,
child: TextButton(
style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.zero),
),
child: Text('回复', style: Theme.of(context)
.textTheme
.labelMedium),
onPressed: () => weakUpReply!(replyItem, replyLevel),
)),
SizedBox(
height: 32,
child: TextButton(
child: Row(
children: [
Icon(
FontAwesomeIcons.thumbsUp,
size: 16,
color: color,
),
const SizedBox(width: 4),
Text(
replyItem!.like.toString(),
style: TextStyle(
color: color,
fontSize:
Theme.of(context).textTheme.labelSmall!.fontSize),
),
],
),
onPressed: () {},
),
),
const SizedBox(width: 5)
],
);
}
}
// ignore: must_be_immutable
class ReplyItemRow extends StatelessWidget {
ReplyItemRow({
super.key,
this.replies,
this.replyControl,
this.f_rpid,
});
List? replies;
ReplyControl? replyControl;
int? f_rpid;
@override
Widget build(BuildContext context) {
bool isShow = replyControl!.isShow!;
int extraRow = replyControl != null && isShow ? 1 : 0;
return Container(
margin: const EdgeInsets.only(left: 42, right: 4, top: 0),
child: Material(
color: Theme.of(context).colorScheme.onInverseSurface,
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
animationDuration: Duration.zero,
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: replies!.length + extraRow,
itemBuilder: (context, index) {
if (extraRow == 1 && index == replies!.length) {
// 有楼中楼回复,在最后显示
return InkWell(
onTap: () => replyReply(context),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Text.rich(
TextSpan(
style: TextStyle(
fontSize:
Theme.of(context).textTheme.labelMedium!.fontSize,
),
children: [
if (replyControl!.upReply!)
const TextSpan(text: 'up主等人 '),
TextSpan(
text: replyControl!.entryText!,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
)
],
),
),
),
);
} else {
return InkWell(
onTap: () {},
child: Padding(
padding: EdgeInsets.fromLTRB(
8,
index == 0 && (extraRow == 1 || replies!.length > 1)
? 8
: 5,
8,
5),
child: Text.rich(
overflow: extraRow == 1
? TextOverflow.ellipsis
: TextOverflow.visible,
maxLines: extraRow == 1 ? 2 : null,
TextSpan(
recognizer: TapGestureRecognizer()
..onTap = () {
replyReply(context);
},
children: [
TextSpan(
text: replies![index].member.uname + ' ',
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.titleSmall!
.fontSize,
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {
print('跳转至用户主页'),
},
),
if (replies![index].isUp)
WidgetSpan(
child: UpTag(),
),
buildContent(context, replies![index].content),
],
),
),
),
);
}
},
),
),
);
}
void replyReply(context) {
Get.bottomSheet(
barrierColor: Colors.transparent,
useRootNavigator: true,
isScrollControlled: true,
Container(
height: Get.size.height - Get.size.width * 9 / 16 - 45,
color: Theme.of(context).colorScheme.background,
child: Column(
children: [
AppBar(
automaticallyImplyLeading: false,
centerTitle: false,
elevation: 1,
title: Text(
'评论详情',
style: Theme.of(context).textTheme.titleMedium,
),
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () async {
Get.back();
},
)
],
),
Expanded(
child: VideoReplyPanel(
oid: replies!.first.oid,
rpid: f_rpid!,
level: '2',
),
)
],
),
),
persistent: false,
backgroundColor: Theme.of(context).bottomSheetTheme.backgroundColor,
);
}
}
InlineSpan buildContent(BuildContext context, content) {
if (content.emote.isEmpty &&
content.atNameToMid.isEmpty &&
content.jumpUrl.isEmpty &&
content.vote.isEmpty &&
content.pictures.isEmpty) {
return TextSpan(text: content.message,
recognizer: TapGestureRecognizer()
..onTap = ()=> {
print('点击')
},);
}
List<InlineSpan> spanChilds = [];
// 匹配表情
String matchEmote = content.message.splitMapJoin(
RegExp(r"\[.*?\]"),
onMatch: (Match match) {
String matchStr = match[0]!;
int size = content.emote[matchStr]['meta']['size'];
if (content.emote.isNotEmpty) {
if (content.emote.keys.contains(matchStr)) {
spanChilds.add(
WidgetSpan(
child: NetworkImgLayer(
src: content.emote[matchStr]['url'],
type: 'emote',
width: size * 20,
height: size * 20,
),
),
);
} else {
spanChilds.add(TextSpan(text: matchStr));
return matchStr;
}
}
return '';
},
onNonMatch: (String str) {
// 匹配@用户
String matchMember = str;
if (content.atNameToMid.isNotEmpty) {
matchMember = str.splitMapJoin(
RegExp(r"@.*( |:)"),
onMatch: (Match match) {
if (match[0] != null) {
content.atNameToMid.forEach((key, value) {
spanChilds.add(
TextSpan(
text: '@$key ',
style: TextStyle(
fontSize:
Theme.of(context).textTheme.titleSmall!.fontSize,
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {
print('跳转至用户主页'),
},
),
);
});
}
return '';
},
onNonMatch: (String str) {
spanChilds.add(TextSpan(text: str));
return str;
},
);
} else {
matchMember = str;
}
// 匹配 jumpUrl
String matchUrl = matchMember;
if (content.jumpUrl.isNotEmpty) {
List urlKeys = content.jumpUrl.keys.toList();
matchUrl = matchMember.splitMapJoin(
RegExp("(?:${urlKeys.join("|")})"),
onMatch: (Match match) {
String matchStr = match[0]!;
spanChilds.add(
TextSpan(
text: content.jumpUrl[matchStr]['title'],
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {
print('Url 点击'),
},
),
);
spanChilds.add(
WidgetSpan(
child: Icon(
FontAwesomeIcons.magnifyingGlass,
size: 9,
color: Theme.of(context).colorScheme.primary,
),
alignment: PlaceholderAlignment.top,
),
);
return '';
},
onNonMatch: (String str) {
spanChilds.add(TextSpan(text: str));
return str;
},
);
}
str = matchUrl.splitMapJoin(
RegExp(r"\d{1,2}:\d{1,2}"),
onMatch: (Match match) {
String matchStr = match[0]!;
spanChilds.add(
TextSpan(
text: ' $matchStr ',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {
print('time 点击'),
},
),
);
return '';
},
onNonMatch: (str) {
return str;
},
);
if (content.atNameToMid.isEmpty && content.jumpUrl.isEmpty) {
spanChilds.add(TextSpan(text: str));
}
return str;
},
);
// 图片渲染
if (content.pictures.isNotEmpty) {
List picList = [];
int len = content.pictures.length;
if (len == 1) {
Map pictureItem = content.pictures.first;
picList.add(pictureItem['img_src']);
spanChilds.add(const TextSpan(text: '\n'));
spanChilds.add(
WidgetSpan(
child: LayoutBuilder(
builder: (context, BoxConstraints box) {
return GestureDetector(
onTap: () {
Get.toNamed('/preview',
arguments: {'initialPage': 0, 'imgList': picList});
},
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: NetworkImgLayer(
src: pictureItem['img_src'],
width: box.maxWidth / 2,
height: box.maxWidth *
0.5 *
pictureItem['img_height'] /
pictureItem['img_width'],
),
),
);
},
),
),
);
}
if (len > 1) {
List<Widget> list = [];
for (var i = 0; i < len; i++) {
picList.add(content.pictures[i]['img_src']);
list.add(
LayoutBuilder(
builder: (context, BoxConstraints box) {
return GestureDetector(
onTap: () {
Get.toNamed('/preview',
arguments: {'initialPage': i, 'imgList': picList});
},
child: NetworkImgLayer(
src: content.pictures[i]['img_src'],
width: box.maxWidth,
height: box.maxWidth,
),
);
},
),
);
}
spanChilds.add(
WidgetSpan(
child: LayoutBuilder(
builder: (context, BoxConstraints box) {
double maxWidth = box.maxWidth;
double crossCount = len < 3 ? 2 : 3;
double height = maxWidth /
crossCount *
(len % crossCount == 0
? len ~/ crossCount
: len ~/ crossCount + 1) +
6;
return Container(
padding: const EdgeInsets.only(top: 6),
height: height,
child: GridView(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
// 子Item排列规则
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
//横轴元素个数
crossAxisCount: crossCount.toInt(),
//纵轴间距
mainAxisSpacing: 4.0,
//横轴间距
crossAxisSpacing: 4.0,
//子组件宽高长度比例
// childAspectRatio: 1,
),
//GridView中使用的子Widegt
children: list,
),
);
},
),
),
);
}
}
// spanChilds.add(TextSpan(text: matchMember));
return TextSpan(children: spanChilds);
}
class UpTag extends StatelessWidget {
String? tagText;
UpTag({super.key, this.tagText = 'UP'});
@override
Widget build(BuildContext context) {
Color primary = Theme.of(context).colorScheme.primary;
return Container(
width: 24,
height: 14,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: tagText == 'UP' ? primary : null,
border: Border.all(color: primary)),
margin: const EdgeInsets.only(right: 4),
child: Center(
child: Text(
tagText!,
style: TextStyle(
fontSize: 9,
color: tagText == 'UP'
? Theme.of(context).colorScheme.onPrimary
: primary,
),
),
),
);
}
}

View File

@ -1,18 +1,136 @@
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/pages/video/detail/reply/index.dart';
import 'package:pilipala/pages/video/detail/controller.dart';
import 'package:pilipala/pages/video/detail/introduction/index.dart';
import 'package:pilipala/pages/video/detail/related/index.dart';
class VideoDetailPage extends StatefulWidget {
const VideoDetailPage({super.key});
const VideoDetailPage({Key? key}) : super(key: key);
@override
State<VideoDetailPage> createState() => _VideoDetailPageState();
}
class _VideoDetailPageState extends State<VideoDetailPage> {
final VideoDetailController videoDetailController =
Get.put(VideoDetailController(), tag: Get.arguments['heroTag']);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('videoDetail'),
final double statusBarHeight = MediaQuery.of(context).padding.top;
final double pinnedHeaderHeight = statusBarHeight +
kToolbarHeight +
MediaQuery.of(context).size.width * 9 / 16;
return DefaultTabController(
initialIndex: videoDetailController.tabInitialIndex,
length: videoDetailController.tabs.length, // tab的数量.
child: SafeArea(
top: false,
bottom: false,
child: Scaffold(
body: ExtendedNestedScrollView(
headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
title: const Text("视频详情"),
pinned: true,
elevation: 0,
scrolledUnderElevation: 0,
forceElevated: innerBoxIsScrolled,
expandedHeight: MediaQuery.of(context).size.width * 9 / 16,
collapsedHeight: MediaQuery.of(context).size.width * 9 / 16,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top),
child: LayoutBuilder(
builder: (context, boxConstraints) {
double maxWidth = boxConstraints.maxWidth;
double maxHeight = boxConstraints.maxHeight;
double PR = MediaQuery.of(context).devicePixelRatio;
return Hero(
tag: videoDetailController.heroTag,
child: NetworkImgLayer(
src: videoDetailController.videoItem['pic'],
width: maxWidth,
height: maxHeight,
),
);
},
),
),
),
),
];
},
pinnedHeaderSliverHeightBuilder: () {
return pinnedHeaderHeight;
},
onlyOneScrollInBody: true,
body: Column(
children: [
Container(
height: 45,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Container(
width: 280,
margin: const EdgeInsets.only(left: 20),
child: Obx(
() => TabBar(
dividerColor: Colors.transparent,
tabs: videoDetailController.tabs
.map((String name) => Tab(text: name))
.toList(),
),
),
),
// 弹幕开关
// const Spacer(),
// Flexible(
// flex: 2,
// child: Container(
// height: 50,
// ),
// ),
],
),
),
Expanded(
child: TabBarView(
children: [
Builder(
builder: (context) {
return const CustomScrollView(
key: PageStorageKey<String>('简介'),
slivers: <Widget>[
VideoIntroPanel(),
RelatedVideoPanel(),
],
);
},
),
VideoReplyPanel()
],
),
),
],
),
),
),
),
);
}

View File

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
class ExpandedSection extends StatefulWidget {
final Widget child;
final bool expand;
double begin = 0.0;
double end = 1.0;
ExpandedSection(
{this.expand = false,
required this.child,
required this.begin,
required this.end});
@override
_ExpandedSectionState createState() => _ExpandedSectionState();
}
class _ExpandedSectionState extends State<ExpandedSection>
with SingleTickerProviderStateMixin {
late AnimationController expandController;
late Animation<double> animation;
@override
void initState() {
super.initState();
prepareAnimations();
_runExpandCheck();
}
///Setting up the animation
// void prepareAnimations() {
// expandController = AnimationController(
// vsync: this, duration: const Duration(milliseconds: 500));
// animation = CurvedAnimation(
// parent: expandController,
// curve: Curves.fastOutSlowIn,
// );
// }
void prepareAnimations() {
expandController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 400));
Animation<double> curve = CurvedAnimation(
parent: expandController,
curve: Curves.fastOutSlowIn,
);
animation = Tween(begin: widget.begin, end: widget.end).animate(curve);
// animation = CurvedAnimation(
// parent: expandController,
// curve: Curves.fastOutSlowIn,
// );
}
void _runExpandCheck() {
if (widget.expand) {
expandController.forward();
} else {
expandController.reverse();
}
}
@override
void didUpdateWidget(ExpandedSection oldWidget) {
super.didUpdateWidget(oldWidget);
_runExpandCheck();
}
@override
void dispose() {
expandController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizeTransition(
axisAlignment: -1.0,
sizeFactor: animation,
child: widget.child,
);
}
}

View File

@ -0,0 +1,85 @@
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:pilipala/http/constants.dart';
import 'package:pilipala/http/init.dart';
import 'package:pilipala/http/user.dart';
import 'package:pilipala/pages/home/index.dart';
import 'package:pilipala/pages/mine/index.dart';
import 'package:pilipala/utils/cookie.dart';
import 'package:pilipala/utils/storage.dart';
import 'package:webview_cookie_manager/webview_cookie_manager.dart';
import 'package:webview_flutter/webview_flutter.dart';
class WebviewController extends GetxController {
String url = '';
String type = '';
String pageTitle = '';
final WebViewController controller = WebViewController();
@override
void onInit() {
super.onInit();
url = Get.parameters['url']!;
type = Get.parameters['type']!;
pageTitle = Get.parameters['pageTitle']!;
webviewInit();
if (type == 'login') {
controller.clearCache();
controller.clearLocalStorage();
WebViewCookieManager().clearCookies();
controller.setUserAgent(Request().headerUa('mob'));
}
}
webviewInit() {
controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
// 页面加载
onProgress: (int progress) {
// Update loading bar.
},
onPageStarted: (String url) {},
// 加载完成
onPageFinished: (String url) async {
if (url.startsWith(
'https://passport.bilibili.com/web/sso/exchange_cookie') ||
url.startsWith('https://m.bilibili.com/')) {
try {
var cookies =
await WebviewCookieManager().getCookies(HttpString.baseUrl);
var apiCookies =
await WebviewCookieManager().getCookies(HttpString.baseUrl);
await SetCookie.onSet(cookies, HttpString.baseUrl);
await SetCookie.onSet(apiCookies, HttpString.baseApiUrl);
await UserHttp.userInfo();
var result = await UserHttp.userInfo();
print('网页登录: $result');
if (result['status'] && result['data'].isLogin) {
SmartDialog.showToast('登录成功');
Box user = GStrorage.user;
user.put(UserBoxKey.userLogin, true);
Get.find<MineController>().userInfo.value = result['data'];
Get.find<HomeController>().queryRcmdFeed('onRefresh');
Get.back();
}
} catch (e) {
print(e);
}
}
},
onWebResourceError: (WebResourceError error) {},
onNavigationRequest: (NavigationRequest request) {
if (request.url.startsWith('https://www.youtube.com/')) {
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse(url));
}
}

View File

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

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'controller.dart';
import 'package:webview_flutter/webview_flutter.dart';
class WebviewPage extends StatefulWidget {
const WebviewPage({super.key});
@override
State<WebviewPage> createState() => _WebviewPageState();
}
class _WebviewPageState extends State<WebviewPage> {
final WebviewController _webviewController = Get.put(WebviewController());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: false,
title: Text(
_webviewController.pageTitle,
style: Theme.of(context).textTheme.titleMedium,
),
),
body: WebViewWidget(controller: _webviewController.controller),
);
}
}

View File

@ -1,7 +1,13 @@
import 'package:get/get.dart';
import 'package:pilipala/pages/fav/index.dart';
import 'package:pilipala/pages/favDetail/index.dart';
import 'package:pilipala/pages/home/index.dart';
import 'package:pilipala/pages/hot/index.dart';
import 'package:pilipala/pages/preview/index.dart';
import 'package:pilipala/pages/video/detail/index.dart';
import 'package:pilipala/pages/webview/index.dart';
import 'package:pilipala/pages/setting/index.dart';
import 'package:pilipala/pages/media/index.dart';
class Routes {
static final List<GetPage> getPages = [
@ -11,5 +17,17 @@ class Routes {
GetPage(name: '/hot', page: () => const HotPage()),
// 视频详情
GetPage(name: '/video', page: () => const VideoDetailPage()),
// 图片预览
GetPage(name: '/preview', page: () => const ImagePreview()),
//
GetPage(name: '/webview', page: () => const WebviewPage()),
// 设置
GetPage(name: '/setting', page: () => const SettingPage()),
//
GetPage(name: '/media', page: () => const MediaPage()),
//
GetPage(name: '/fav', page: () => const FavPage()),
//
GetPage(name: '/favDetail', page: () => const FavDetailPage()),
];
}

26
lib/utils/cookie.dart Normal file
View File

@ -0,0 +1,26 @@
import 'dart:io';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:pilipala/http/init.dart';
import 'package:pilipala/utils/utils.dart';
class SetCookie {
static onSet(List cookiesList, String url) async {
// domain url
List<Cookie> jarCookies = [];
if (cookiesList.isNotEmpty) {
for (var i in cookiesList) {
Cookie jarCookie = Cookie(i.name, i.value);
jarCookies.add(jarCookie);
}
}
String cookiePath = await Utils.getCookiePath();
PersistCookieJar cookieJar = PersistCookieJar(
ignoreExpires: true,
storage: FileStorage(cookiePath),
);
await cookieJar.saveFromResponse(Uri.parse(url), jarCookies);
// 重新设置 cookie
Request.setCookie();
return true;
}
}

24
lib/utils/storage.dart Normal file
View File

@ -0,0 +1,24 @@
import 'package:hive/hive.dart';
import 'package:path_provider/path_provider.dart';
class GStrorage {
static late final Box user;
static Future<void> init() async {
final dir = await getApplicationDocumentsDirectory();
final path = dir.path;
Hive.init('$path/hive');
user = await Hive.openBox('user');
}
}
// 约定 key
class UserBoxKey {
static const String userName = 'userName';
// 头像
static const String userFace = 'userFace';
// mid
static const String userMid = 'userMid';
// 登录状态
static const String userLogin = 'userLogin';
}

View File

@ -1,5 +1,6 @@
// 工具函数
import 'dart:io';
import 'dart:math';
import 'package:get/get_utils/get_utils.dart';
import 'package:path_provider/path_provider.dart';
@ -130,4 +131,8 @@ class Utils {
}
return date;
}
static String makeHeroTag(v) {
return v.toString() + Random().nextInt(9999).toString();
}
}