diff --git a/lib/http/api.dart b/lib/http/api.dart index 57e776bf..3301c1dc 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -190,4 +190,10 @@ class Api { // ?page=1&page_size=30&platform=web static const String liveList = 'https://api.live.bilibili.com/xlive/web-interface/v1/second/getUserRecommend'; + + // 直播间详情 + // cid roomId + // qn 80:流畅,150:高清,400:蓝光,10000:原画,20000:4K, 30000:杜比 + static const String liveRoomInfo = + 'https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo'; } diff --git a/lib/http/live.dart b/lib/http/live.dart index 8b43fea6..2ae9aad7 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -1,6 +1,7 @@ import 'package:pilipala/http/api.dart'; import 'package:pilipala/http/init.dart'; import 'package:pilipala/models/live/item.dart'; +import 'package:pilipala/models/live/room_info.dart'; class LiveHttp { static Future liveList( @@ -22,4 +23,27 @@ class LiveHttp { }; } } + + static Future liveRoomInfo({roomId, qn}) async { + var res = await Request().get(Api.liveRoomInfo, data: { + 'room_id': roomId, + 'protocol': '0, 1', + 'format': '0, 1, 2', + 'codec': '0, 1', + 'qn': qn, + 'platform': 'web', + 'ptype': 8, + 'dolby': 5, + 'panorama': 1, + }); + if (res.data['code'] == 0) { + return {'status': true, 'data': RoomInfoModel.fromJson(res.data['data'])}; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/models/live/item.dart b/lib/models/live/item.dart index 4cf3aefc..d3789d5b 100644 --- a/lib/models/live/item.dart +++ b/lib/models/live/item.dart @@ -50,7 +50,7 @@ class LiveItemModel { Map? watchedShow; LiveItemModel.fromJson(Map json) { - roomId = json['room_id']; + roomId = json['roomid']; uid = json['uid']; title = json['title']; uname = json['uname']; diff --git a/lib/models/live/room_info.dart b/lib/models/live/room_info.dart new file mode 100644 index 00000000..7209ba08 --- /dev/null +++ b/lib/models/live/room_info.dart @@ -0,0 +1,155 @@ +class RoomInfoModel { + RoomInfoModel({ + this.roomId, + this.liveStatus, + this.liveTime, + this.playurlInfo, + }); + int? roomId; + int? liveStatus; + int? liveTime; + PlayurlInfo? playurlInfo; + + RoomInfoModel.fromJson(Map json) { + roomId = json['room_id']; + liveStatus = json['live_status']; + liveTime = json['live_time']; + playurlInfo = PlayurlInfo.fromJson(json['playurl_info']); + } +} + +class PlayurlInfo { + PlayurlInfo({ + this.playurl, + }); + + Playurl? playurl; + + PlayurlInfo.fromJson(Map json) { + playurl = Playurl.fromJson(json['playurl']); + } +} + +class Playurl { + Playurl({ + this.cid, + this.gQnDesc, + this.stream, + }); + + int? cid; + List? gQnDesc; + List? stream; + + Playurl.fromJson(Map json) { + cid = json['cid']; + gQnDesc = + json['g_qn_desc'].map((e) => GQnDesc.fromJson(e)).toList(); + stream = json['stream'].map((e) => Streams.fromJson(e)).toList(); + } +} + +class GQnDesc { + GQnDesc({ + this.qn, + this.desc, + this.hdrDesc, + this.attrDesc, + }); + + int? qn; + String? desc; + String? hdrDesc; + String? attrDesc; + + GQnDesc.fromJson(Map json) { + qn = json['qn']; + desc = json['desc']; + hdrDesc = json['hedr_desc']; + attrDesc = json['attr_desc']; + } +} + +class Streams { + Streams({ + this.protocolName, + this.format, + }); + + String? protocolName; + List? format; + + Streams.fromJson(Map json) { + protocolName = json['protocol_name']; + format = + json['format'].map((e) => FormatItem.fromJson(e)).toList(); + } +} + +class FormatItem { + FormatItem({ + this.formatName, + this.codec, + }); + + String? formatName; + List? codec; + + FormatItem.fromJson(Map json) { + formatName = json['format_name']; + codec = json['codec'].map((e) => CodecItem.fromJson(e)).toList(); + } +} + +class CodecItem { + CodecItem({ + this.codecName, + this.currentQn, + this.acceptQn, + this.baseUrl, + this.urlInfo, + this.hdrQn, + this.dolbyType, + this.attrName, + }); + + String? codecName; + int? currentQn; + List? acceptQn; + String? baseUrl; + List? urlInfo; + String? hdrQn; + int? dolbyType; + String? attrName; + + CodecItem.fromJson(Map json) { + codecName = json['codec_name']; + currentQn = json['current_qn']; + acceptQn = json['accept_qn']; + baseUrl = json['base_url']; + urlInfo = json['url_info'] + .map((e) => UrlInfoItem.fromJson(e)) + .toList(); + hdrQn = json['hdr_n']; + dolbyType = json['dolby_type']; + attrName = json['attr_name']; + } +} + +class UrlInfoItem { + UrlInfoItem({ + this.host, + this.extra, + this.streamTtl, + }); + + String? host; + String? extra; + int? streamTtl; + + UrlInfoItem.fromJson(Map json) { + host = json['host']; + extra = json['extra']; + streamTtl = json['stream_ttl']; + } +} diff --git a/lib/pages/live/widgets/live_item.dart b/lib/pages/live/widgets/live_item.dart index f13ee0eb..4768a4eb 100644 --- a/lib/pages/live/widgets/live_item.dart +++ b/lib/pages/live/widgets/live_item.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:pilipala/common/constants.dart'; import 'package:pilipala/models/live/item.dart'; import 'package:pilipala/pages/video/detail/reply/widgets/reply_item.dart'; @@ -22,7 +23,7 @@ class LiveCardV extends StatelessWidget { Widget build(BuildContext context) { String heroTag = Utils.makeHeroTag(liveItem.roomId); return Card( - elevation: 0, + elevation: 0.8, clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: StyleString.mdRadius, @@ -42,13 +43,16 @@ class LiveCardV extends StatelessWidget { child: InkWell( onTap: () async { await Future.delayed(const Duration(milliseconds: 200)); - // Get.toNamed('/video?bvid=${liveItem.bvid}&cid=${liveItem.cid}', - // arguments: {'videoItem': liveItem, 'heroTag': heroTag}); + Get.toNamed('/liveRoom?roomid=${liveItem.roomId}', + arguments: {'liveItem': liveItem, 'heroTag': heroTag}); }, child: Column( children: [ ClipRRect( - borderRadius: const BorderRadius.all(StyleString.imgRadius), + borderRadius: const BorderRadius.only( + topLeft: StyleString.imgRadius, + topRight: StyleString.imgRadius, + ), child: AspectRatio( aspectRatio: StyleString.aspectRatio, child: LayoutBuilder(builder: (context, boxConstraints) { @@ -86,7 +90,7 @@ class LiveContent extends StatelessWidget { return Expanded( child: Padding( // 多列 - padding: const EdgeInsets.fromLTRB(4, 8, 6, 7), + padding: const EdgeInsets.fromLTRB(8, 8, 6, 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -99,7 +103,7 @@ class LiveContent extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 3), + const SizedBox(height: 4), Row( children: [ UpTag(), @@ -116,6 +120,7 @@ class LiveContent extends StatelessWidget { ) ], ), + const SizedBox(height: 2), Row( children: [ Text( diff --git a/lib/pages/liveRoom/controller.dart b/lib/pages/liveRoom/controller.dart new file mode 100644 index 00000000..6ede0ede --- /dev/null +++ b/lib/pages/liveRoom/controller.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_meedu_media_kit/meedu_player.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/constants.dart'; +import 'package:pilipala/http/live.dart'; +import 'package:pilipala/models/live/room_info.dart'; + +class LiveRoomController extends GetxController { + String cover = ''; + late int roomId; + var liveItem; + + MeeduPlayerController meeduPlayerController = MeeduPlayerController( + colorTheme: Theme.of(Get.context!).colorScheme.primary, + pipEnabled: true, + controlsStyle: ControlsStyle.youtube, + enabledButtons: const EnabledButtons(pip: true), + ); + + @override + void onInit() { + super.onInit(); + var args = Get.arguments['liveItem']; + liveItem = args; + print(liveItem.roomId); + roomId = liveItem.roomId!; + if (args.pic != null && args.pic != '') { + cover = args.cover; + } + queryLiveInfo(); + } + + playerInit(source) { + meeduPlayerController.setDataSource( + DataSource( + type: DataSourceType.network, + source: source, + httpHeaders: { + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15', + 'referer': HttpString.baseUrl + }, + ), + autoplay: true, + ); + } + + Future queryLiveInfo() async { + var res = await LiveHttp.liveRoomInfo(roomId: roomId, qn: 10000); + if (res['status']) { + List codec = + res['data'].playurlInfo.playurl.stream.first.format.first.codec; + CodecItem item = codec.first; + String videoUrl = (item.urlInfo?.first.host)! + + item.baseUrl! + + item.urlInfo!.first.extra!; + playerInit(videoUrl); + } + } +} diff --git a/lib/pages/liveRoom/index.dart b/lib/pages/liveRoom/index.dart new file mode 100644 index 00000000..ce4bea9f --- /dev/null +++ b/lib/pages/liveRoom/index.dart @@ -0,0 +1,4 @@ +library liveroom; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/liveRoom/view.dart b/lib/pages/liveRoom/view.dart new file mode 100644 index 00000000..b79dff4e --- /dev/null +++ b/lib/pages/liveRoom/view.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_meedu_media_kit/meedu_player.dart'; +import 'package:get/get.dart'; +import 'dart:ui'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; + +import 'controller.dart'; + +class LiveRoomPage extends StatefulWidget { + const LiveRoomPage({super.key}); + + @override + State createState() => _LiveRoomPageState(); +} + +class _LiveRoomPageState extends State { + final LiveRoomController _liveRoomController = Get.put(LiveRoomController()); + MeeduPlayerController? _meeduPlayerController; + + @override + void initState() { + super.initState(); + _meeduPlayerController = _liveRoomController.meeduPlayerController; + } + + @override + Widget build(BuildContext context) { + final double statusBarHeight = MediaQuery.of(context).padding.top; + final videoHeight = MediaQuery.of(context).size.width * 9 / 16; + + return Scaffold( + primary: true, + appBar: AppBar( + centerTitle: false, + titleSpacing: 0, + title: Row( + children: [ + NetworkImgLayer( + width: 34, + height: 34, + type: 'avatar', + src: _liveRoomController.liveItem.face, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _liveRoomController.liveItem.uname, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 3), + Text(_liveRoomController.liveItem.title, + style: const TextStyle(fontSize: 12)), + ], + ) + ], + ), + ), + body: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + child: NetworkImgLayer( + type: 'emote', + src: _liveRoomController.cover, + width: Get.size.width, + height: videoHeight + 45, + ), + ), + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.background.withOpacity(0.1), + ), + ), + ), + ), + Scaffold( + backgroundColor: Colors.transparent, + body: Column( + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: MeeduVideoPlayer( + controller: _meeduPlayerController!, + ), + ), + Container( + color: Theme.of(context).colorScheme.background, + height: 45, + padding: const EdgeInsets.only(left: 12, right: 12), + child: Row(children: [ + Text( + _liveRoomController.liveItem.watchedShow['text_large']), + ]), + ), + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 150b0388..899aa4ca 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -9,6 +9,7 @@ import 'package:pilipala/pages/history/index.dart'; import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/hot/index.dart'; import 'package:pilipala/pages/later/index.dart'; +import 'package:pilipala/pages/liveRoom/view.dart'; import 'package:pilipala/pages/preview/index.dart'; import 'package:pilipala/pages/search/index.dart'; import 'package:pilipala/pages/searchResult/index.dart'; @@ -59,5 +60,7 @@ class Routes { GetPage(name: '/follow', page: () => const FollowPage()), // 粉丝 GetPage(name: '/fan', page: () => const FansPage()), + // 直播详情 + GetPage(name: '/liveRoom', page: () => const LiveRoomPage()), ]; }