From ea5d7593fff77a2b196d279fcf8a912ae4d284fa Mon Sep 17 00:00:00 2001 From: dom Date: Thu, 11 Jun 2026 12:16:24 +0800 Subject: [PATCH] switch live stream/format/codec/url support Signed-off-by: dom --- lib/http/live.dart | 12 +- lib/models_new/live/live_dm_info/data.dart | 14 +- .../live/live_room_play_info/codec.dart | 29 ++- .../live/live_room_play_info/format.dart | 13 +- .../live/live_room_play_info/playurl.dart | 8 +- .../live/live_room_play_info/stream.dart | 10 +- .../live/live_room_play_info/url_info.dart | 10 +- lib/pages/live_room/controller.dart | 71 ++++-- .../live_room/widgets/header_control.dart | 238 +++++++++++++++--- lib/utils/extension/iterable_ext.dart | 4 + lib/utils/video_utils.dart | 8 +- 11 files changed, 318 insertions(+), 99 deletions(-) diff --git a/lib/http/live.dart b/lib/http/live.dart index c7b0a154f..ffb07aa5d 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -102,7 +102,11 @@ abstract final class LiveHttp { }), ); if (res.data['code'] == 0) { - return Success(RoomPlayInfoData.fromJson(res.data['data'])); + try { + return Success(RoomPlayInfoData.fromJson(res.data['data'])); + } catch (e) { + return Error(e.toString()); + } } else { return Error(res.data['message']); } @@ -163,7 +167,11 @@ abstract final class LiveHttp { }), ); if (res.data['code'] == 0) { - return Success(LiveDmInfoData.fromJson(res.data['data'])); + try { + return Success(LiveDmInfoData.fromJson(res.data['data'])); + } catch (e) { + return Error(e.toString()); + } } else { return Error(res.data['message']); } diff --git a/lib/models_new/live/live_dm_info/data.dart b/lib/models_new/live/live_dm_info/data.dart index ffca3bee2..519c9db21 100644 --- a/lib/models_new/live/live_dm_info/data.dart +++ b/lib/models_new/live/live_dm_info/data.dart @@ -1,18 +1,18 @@ import 'package:PiliPlus/models_new/live/live_dm_info/host_list.dart'; class LiveDmInfoData { - String? token; - List? hostList; + String token; + List hostList; LiveDmInfoData({ - this.token, - this.hostList, + required this.token, + required this.hostList, }); factory LiveDmInfoData.fromJson(Map json) => LiveDmInfoData( - token: json['token'] as String?, - hostList: (json['host_list'] as List?) - ?.map((e) => HostList.fromJson(e as Map)) + token: json['token'] as String, + hostList: (json['host_list'] as List) + .map((e) => HostList.fromJson(e as Map)) .toList(), ); } diff --git a/lib/models_new/live/live_room_play_info/codec.dart b/lib/models_new/live/live_room_play_info/codec.dart index 1f295cde2..dc4b4e687 100644 --- a/lib/models_new/live/live_room_play_info/codec.dart +++ b/lib/models_new/live/live_room_play_info/codec.dart @@ -2,24 +2,27 @@ import 'package:PiliPlus/models_new/live/live_room_play_info/url_info.dart'; import 'package:PiliPlus/utils/extension/iterable_ext.dart'; class CodecItem { - int? currentQn; - List? acceptQn; - String? baseUrl; - List? urlInfo; + String? codecName; + int currentQn; + List acceptQn; + String baseUrl; + List urlInfo; CodecItem({ - this.currentQn, - this.acceptQn, - this.baseUrl, - this.urlInfo, + this.codecName, + required this.currentQn, + required this.acceptQn, + required this.baseUrl, + required this.urlInfo, }); factory CodecItem.fromJson(Map json) => CodecItem( - currentQn: json['current_qn'] as int?, - acceptQn: (json['accept_qn'] as List?)?.fromCast(), - baseUrl: json['base_url'] as String?, - urlInfo: (json['url_info'] as List?) - ?.map((e) => UrlInfo.fromJson(e as Map)) + codecName: json['codec_name'], + currentQn: json['current_qn'] as int, + acceptQn: (json['accept_qn'] as List).fromCast(), + baseUrl: json['base_url'] as String, + urlInfo: (json['url_info'] as List) + .map((e) => UrlInfo.fromJson(e as Map)) .toList(), ); } diff --git a/lib/models_new/live/live_room_play_info/format.dart b/lib/models_new/live/live_room_play_info/format.dart index afd8de5c5..35dba67a7 100644 --- a/lib/models_new/live/live_room_play_info/format.dart +++ b/lib/models_new/live/live_room_play_info/format.dart @@ -1,13 +1,18 @@ import 'package:PiliPlus/models_new/live/live_room_play_info/codec.dart'; class Format { - List? codec; + String? formatName; + List codec; - Format({this.codec}); + Format({ + this.formatName, + required this.codec, + }); factory Format.fromJson(Map json) => Format( - codec: (json['codec'] as List?) - ?.map((e) => CodecItem.fromJson(e as Map)) + formatName: json['format_name'], + codec: (json['codec'] as List) + .map((e) => CodecItem.fromJson(e as Map)) .toList(), ); } diff --git a/lib/models_new/live/live_room_play_info/playurl.dart b/lib/models_new/live/live_room_play_info/playurl.dart index 07977bb3d..63421a37d 100644 --- a/lib/models_new/live/live_room_play_info/playurl.dart +++ b/lib/models_new/live/live_room_play_info/playurl.dart @@ -1,15 +1,15 @@ import 'package:PiliPlus/models_new/live/live_room_play_info/stream.dart'; class Playurl { - List? stream; + List stream; Playurl({ - this.stream, + required this.stream, }); factory Playurl.fromJson(Map json) => Playurl( - stream: (json['stream'] as List?) - ?.map((e) => Stream.fromJson(e as Map)) + stream: (json['stream'] as List) + .map((e) => Stream.fromJson(e as Map)) .toList(), ); } diff --git a/lib/models_new/live/live_room_play_info/stream.dart b/lib/models_new/live/live_room_play_info/stream.dart index 18b3a6578..7aa09ff40 100644 --- a/lib/models_new/live/live_room_play_info/stream.dart +++ b/lib/models_new/live/live_room_play_info/stream.dart @@ -1,13 +1,15 @@ import 'package:PiliPlus/models_new/live/live_room_play_info/format.dart'; class Stream { - List? format; + String? protocolName; + List format; - Stream({this.format}); + Stream({this.protocolName, required this.format}); factory Stream.fromJson(Map json) => Stream( - format: (json['format'] as List?) - ?.map((e) => Format.fromJson(e as Map)) + protocolName: json['protocol_name'], + format: (json['format'] as List) + .map((e) => Format.fromJson(e as Map)) .toList(), ); } diff --git a/lib/models_new/live/live_room_play_info/url_info.dart b/lib/models_new/live/live_room_play_info/url_info.dart index d32d111d3..458b0dab4 100644 --- a/lib/models_new/live/live_room_play_info/url_info.dart +++ b/lib/models_new/live/live_room_play_info/url_info.dart @@ -1,11 +1,11 @@ class UrlInfo { - String? host; - String? extra; + String host; + String extra; - UrlInfo({this.host, this.extra}); + UrlInfo({required this.host, required this.extra}); factory UrlInfo.fromJson(Map json) => UrlInfo( - host: json['host'] as String?, - extra: json['extra'] as String?, + host: json['host'] as String, + extra: json['extra'] as String, ); } diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index 4a3252c44..cd773ff9a 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -16,6 +16,7 @@ import 'package:PiliPlus/models_new/live/live_dm_info/data.dart'; import 'package:PiliPlus/models_new/live/live_medal_wall/uinfo_medal.dart'; import 'package:PiliPlus/models_new/live/live_room_info_h5/data.dart'; import 'package:PiliPlus/models_new/live/live_room_play_info/codec.dart'; +import 'package:PiliPlus/models_new/live/live_room_play_info/stream.dart'; import 'package:PiliPlus/models_new/live/live_superchat/item.dart'; import 'package:PiliPlus/pages/common/publish/publish_route.dart'; import 'package:PiliPlus/pages/danmaku/danmaku_model.dart'; @@ -195,38 +196,68 @@ class LiveRoomController extends GetxController { _showDialog('当前直播间未开播'); return; } - if (response.playurlInfo?.playurl == null) { + final playurl = response.playurlInfo?.playurl; + if (playurl == null) { _showDialog('无法获取播放地址'); return; } ruid = response.uid; - if (response.roomId != null) { - roomId = response.roomId!; + if (response.roomId case final roomId?) { + this.roomId = roomId; } liveTime.value = response.liveTime; startLiveTimer(); isPortrait.value = response.isPortrait ?? false; - List codec = - response.playurlInfo!.playurl!.stream!.first.format!.first.codec!; - CodecItem item = codec.first; - // 以服务端返回的码率为准 - currentQn = item.currentQn!; - acceptQnList = item.acceptQn!.map((e) { - return ( - code: e, - desc: LiveQuality.fromCode(e)?.desc ?? e.toString(), - ); - }).toList(); - currentQnDesc.value = - LiveQuality.fromCode(currentQn)?.desc ?? currentQn.toString(); - videoUrl = VideoUtils.getLiveCdnUrl(item); - await playerInit(autoFullScreenFlag: autoFullScreenFlag); + stream = playurl.stream; + await initLiveUrl( + streamIndex: streamIndex, + formatIndex: formatIndex, + codecIndex: codecIndex, + liveUrlIndex: liveUrlIndex, + ); isLoaded.value = true; } else { _showDialog(res.toString()); } } + late List stream; + int streamIndex = 0; + int formatIndex = 0; + int codecIndex = 0; + int liveUrlIndex = 0; + + Future? initLiveUrl({ + int streamIndex = 0, + int formatIndex = 0, + int codecIndex = 0, + int liveUrlIndex = 0, + }) { + this.streamIndex = streamIndex; + this.formatIndex = formatIndex; + this.codecIndex = codecIndex; + this.liveUrlIndex = liveUrlIndex; + + final CodecItem item = stream + .getOrFirst(streamIndex) + .format + .getOrFirst(formatIndex) + .codec + .getOrFirst(codecIndex); + // 以服务端返回的码率为准 + currentQn = item.currentQn; + acceptQnList = item.acceptQn.map((e) { + return ( + code: e, + desc: LiveQuality.fromCode(e)?.desc ?? e.toString(), + ); + }).toList(); + currentQnDesc.value = + LiveQuality.fromCode(currentQn)?.desc ?? currentQn.toString(); + videoUrl = VideoUtils.getLiveCdnUrl(item, index: liveUrlIndex); + return playerInit(); + } + Future queryLiveInfoH5() async { final res = await LiveHttp.liveRoomInfoH5(roomId: roomId); if (res case Success(:final response)) { @@ -414,10 +445,10 @@ class LiveRoomController extends GetxController { } _msgStream = LiveMessageStream( - streamToken: info.token!, + streamToken: info.token, roomId: roomId, uid: Accounts.heartbeat.mid, - servers: info.hostList! + servers: info.hostList .map((host) => 'wss://${host.host}:${host.wssPort}/sub') .toList(), ) diff --git a/lib/pages/live_room/widgets/header_control.dart b/lib/pages/live_room/widgets/header_control.dart index 96abbf144..8739ef509 100644 --- a/lib/pages/live_room/widgets/header_control.dart +++ b/lib/pages/live_room/widgets/header_control.dart @@ -1,6 +1,10 @@ -import 'dart:io'; +import 'dart:io' show Platform; +import 'dart:math' as math; +import 'package:PiliPlus/common/style.dart'; +import 'package:PiliPlus/common/widgets/flutter/draggable_scrollable_sheet.dart'; import 'package:PiliPlus/common/widgets/marquee.dart'; +import 'package:PiliPlus/models/common/video/live_quality.dart'; import 'package:PiliPlus/pages/live_room/controller.dart'; import 'package:PiliPlus/pages/setting/models/play_settings.dart' show showPlayerVolumeDialog; @@ -10,6 +14,8 @@ import 'package:PiliPlus/plugin/pl_player/widgets/common_btn.dart'; import 'package:PiliPlus/services/shutdown_timer_service.dart' show shutdownTimerService; import 'package:PiliPlus/utils/android/bindings.g.dart'; +import 'package:PiliPlus/utils/extension/context_ext.dart'; +import 'package:PiliPlus/utils/extension/size_ext.dart'; import 'package:PiliPlus/utils/extension/string_ext.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:flutter/material.dart'; @@ -237,28 +243,39 @@ class _LiveHeaderControlState extends State ), ), if (plPlayerController.videoPlayerController case final player?) - if (PlatformUtils.isMobile) - SizedBox.square( - dimension: 30, - child: PopupMenuButton( - iconSize: 18, - padding: .zero, - iconColor: Colors.white, - itemBuilder: (context) => [ - PopupMenuItem( - height: 35, - child: const Row( - spacing: 8, - children: [ - Icon(Icons.info_outline, size: 17), - Text('播放信息', style: TextStyle(fontSize: 14)), - ], - ), - onTap: () => HeaderControlState.showPlayerInfo( - context, - player: player, - ), + SizedBox.square( + dimension: 30, + child: PopupMenuButton( + iconSize: 18, + padding: .zero, + iconColor: Colors.white, + itemBuilder: (context) => [ + PopupMenuItem( + height: 35, + onTap: _showLiveStreamDialog, + child: const Row( + spacing: 8, + children: [ + Icon(Icons.alt_route, size: 17), + Text('切换路线', style: TextStyle(fontSize: 14)), + ], ), + ), + PopupMenuItem( + height: 35, + child: const Row( + spacing: 8, + children: [ + Icon(Icons.info_outline, size: 17), + Text('播放信息', style: TextStyle(fontSize: 14)), + ], + ), + onTap: () => HeaderControlState.showPlayerInfo( + context, + player: player, + ), + ), + if (PlatformUtils.isMobile) PopupMenuItem( height: 35, child: Row( @@ -277,23 +294,172 @@ class _LiveHeaderControlState extends State onChanged: player.setVolume, ), ), - ], - ), - ) - else - ComBtn( - height: 30, - tooltip: '播放信息', - onTap: () => - HeaderControlState.showPlayerInfo(context, player: player), - icon: const Icon( - size: 18, - Icons.info_outline, - color: Colors.white, - ), + ], ), + ), ], ), ); } + + void _showLiveStreamDialog() { + final controller = widget.liveController; + showModalBottomSheet( + context: context, + useSafeArea: true, + clipBehavior: .hardEdge, + isScrollControlled: true, + constraints: BoxConstraints( + maxWidth: math.min(640, context.mediaQueryShortestSide), + ), + builder: (context) { + final maxChildSize = + PlatformUtils.isMobile && !context.mediaQuerySize.isPortrait + ? 1.0 + : 0.7; + return DynDraggableScrollableSheet( + minChildSize: 0, + maxChildSize: maxChildSize, + snap: true, + expand: false, + snapSizes: [maxChildSize], + initialChildSize: maxChildSize, + builder: (context, scrollController) { + final theme = Theme.of(context); + final secondary = theme.colorScheme.secondary; + final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; + final currStyle = TextStyle(fontSize: 14, color: secondary); + return Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ListView( + controller: scrollController, + padding: .only( + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, + ), + children: [ + InkWell( + onTap: Get.back, + borderRadius: Style.bottomSheetRadius, + child: SizedBox( + height: 35, + child: Center( + child: Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: theme.colorScheme.outline, + borderRadius: const .all(.circular(1.5)), + ), + ), + ), + ), + ), + ...controller.stream.indexed.map((stream) { + final isCurrStream = stream.$1 == controller.streamIndex; + final streamColor = isCurrStream + ? secondary + : onSurfaceVariant; + return _ExpansionTile( + initiallyExpanded: isCurrStream, + iconColor: streamColor, + collapsedIconColor: streamColor, + title: Text( + stream.$2.protocolName ?? stream.$1.toString(), + style: isCurrStream + ? currStyle + : const TextStyle(fontSize: 14), + ), + children: stream.$2.format.indexed.map((format) { + final isCurrFormat = + isCurrStream && format.$1 == controller.formatIndex; + final formatColor = isCurrFormat + ? secondary + : onSurfaceVariant; + return _ExpansionTile( + initiallyExpanded: isCurrFormat, + iconColor: formatColor, + collapsedIconColor: formatColor, + title: Text( + format.$2.formatName ?? format.$1.toString(), + style: isCurrFormat + ? currStyle + : const TextStyle(fontSize: 14), + ), + children: format.$2.codec.indexed.map((codec) { + final e = codec.$2; + final isCurrCodec = + isCurrFormat && + codec.$1 == controller.codecIndex; + final codecColor = isCurrCodec + ? secondary + : onSurfaceVariant; + return _ExpansionTile( + initiallyExpanded: isCurrCodec, + iconColor: codecColor, + collapsedIconColor: codecColor, + title: Text( + '${e.codecName ?? codec.$1.toString()} (${LiveQuality.fromCode(e.currentQn)?.desc ?? e.currentQn})', + style: isCurrCodec + ? currStyle + : const TextStyle(fontSize: 14), + ), + children: e.urlInfo.indexed.map((url) { + final isCurrUrl = + (isCurrCodec && + url.$1 == controller.liveUrlIndex); + return ListTile( + dense: true, + title: Text( + '${url.$2.host}${e.baseUrl}...', + style: isCurrUrl + ? const TextStyle(fontSize: 14) + : TextStyle( + fontSize: 14, + color: onSurfaceVariant, + ), + ), + selected: isCurrUrl, + onTap: isCurrUrl + ? null + : () { + Get.back(); + controller.initLiveUrl( + streamIndex: stream.$1, + formatIndex: format.$1, + codecIndex: codec.$1, + liveUrlIndex: url.$1, + ); + }, + ); + }).toList(), + ); + }).toList(), + ); + }).toList(), + ); + }), + ], + ), + ); + }, + ); + }, + ); + } +} + +class _ExpansionTile extends ExpansionTile { + const _ExpansionTile({ + required super.title, + // ignore: unused_element_parameter + super.dense = true, + // ignore: unused_element_parameter + super.controlAffinity = .leading, + // ignore: unused_element_parameter + super.childrenPadding = const .only(left: 20), + super.initiallyExpanded, + super.iconColor, + super.collapsedIconColor, + super.children, + }); } diff --git a/lib/utils/extension/iterable_ext.dart b/lib/utils/extension/iterable_ext.dart index 8d820c876..036fa1ea1 100644 --- a/lib/utils/extension/iterable_ext.dart +++ b/lib/utils/extension/iterable_ext.dart @@ -64,4 +64,8 @@ extension ListExt on List { if (index < 0 || index >= length) return null; return this[index]; } + + T getOrFirst(int index) { + return getOrNull(index) ?? first; + } } diff --git a/lib/utils/video_utils.dart b/lib/utils/video_utils.dart index c82b7b710..fa66cb20b 100644 --- a/lib/utils/video_utils.dart +++ b/lib/utils/video_utils.dart @@ -1,5 +1,6 @@ import 'package:PiliPlus/models/common/video/cdn_type.dart'; import 'package:PiliPlus/models_new/live/live_room_play_info/codec.dart'; +import 'package:PiliPlus/utils/extension/iterable_ext.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:flutter/foundation.dart' show kDebugMode, debugPrint; @@ -88,9 +89,8 @@ abstract final class VideoUtils { .toString(); } - static String getLiveCdnUrl(CodecItem e) { - return (liveCdnUrl ?? e.urlInfo!.first.host!) + - e.baseUrl! + - e.urlInfo!.first.extra!; + static String getLiveCdnUrl(CodecItem e, {int index = 0}) { + final urlInfo = e.urlInfo.getOrFirst(index); + return (liveCdnUrl ?? urlInfo.host) + e.baseUrl + urlInfo.extra; } }