switch live stream/format/codec/url support

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-06-11 12:16:24 +08:00
parent aa35569ebe
commit ea5d7593ff
11 changed files with 318 additions and 99 deletions

View File

@@ -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']);
}

View File

@@ -1,18 +1,18 @@
import 'package:PiliPlus/models_new/live/live_dm_info/host_list.dart';
class LiveDmInfoData {
String? token;
List<HostList>? hostList;
String token;
List<HostList> hostList;
LiveDmInfoData({
this.token,
this.hostList,
required this.token,
required this.hostList,
});
factory LiveDmInfoData.fromJson(Map<String, dynamic> json) => LiveDmInfoData(
token: json['token'] as String?,
hostList: (json['host_list'] as List<dynamic>?)
?.map((e) => HostList.fromJson(e as Map<String, dynamic>))
token: json['token'] as String,
hostList: (json['host_list'] as List<dynamic>)
.map((e) => HostList.fromJson(e as Map<String, dynamic>))
.toList(),
);
}

View File

@@ -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<int>? acceptQn;
String? baseUrl;
List<UrlInfo>? urlInfo;
String? codecName;
int currentQn;
List<int> acceptQn;
String baseUrl;
List<UrlInfo> 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<String, dynamic> 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<dynamic>?)
?.map((e) => UrlInfo.fromJson(e as Map<String, dynamic>))
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<dynamic>)
.map((e) => UrlInfo.fromJson(e as Map<String, dynamic>))
.toList(),
);
}

View File

@@ -1,13 +1,18 @@
import 'package:PiliPlus/models_new/live/live_room_play_info/codec.dart';
class Format {
List<CodecItem>? codec;
String? formatName;
List<CodecItem> codec;
Format({this.codec});
Format({
this.formatName,
required this.codec,
});
factory Format.fromJson(Map<String, dynamic> json) => Format(
codec: (json['codec'] as List<dynamic>?)
?.map((e) => CodecItem.fromJson(e as Map<String, dynamic>))
formatName: json['format_name'],
codec: (json['codec'] as List<dynamic>)
.map((e) => CodecItem.fromJson(e as Map<String, dynamic>))
.toList(),
);
}

View File

@@ -1,15 +1,15 @@
import 'package:PiliPlus/models_new/live/live_room_play_info/stream.dart';
class Playurl {
List<Stream>? stream;
List<Stream> stream;
Playurl({
this.stream,
required this.stream,
});
factory Playurl.fromJson(Map<String, dynamic> json) => Playurl(
stream: (json['stream'] as List<dynamic>?)
?.map((e) => Stream.fromJson(e as Map<String, dynamic>))
stream: (json['stream'] as List<dynamic>)
.map((e) => Stream.fromJson(e as Map<String, dynamic>))
.toList(),
);
}

View File

@@ -1,13 +1,15 @@
import 'package:PiliPlus/models_new/live/live_room_play_info/format.dart';
class Stream {
List<Format>? format;
String? protocolName;
List<Format> format;
Stream({this.format});
Stream({this.protocolName, required this.format});
factory Stream.fromJson(Map<String, dynamic> json) => Stream(
format: (json['format'] as List<dynamic>?)
?.map((e) => Format.fromJson(e as Map<String, dynamic>))
protocolName: json['protocol_name'],
format: (json['format'] as List<dynamic>)
.map((e) => Format.fromJson(e as Map<String, dynamic>))
.toList(),
);
}

View File

@@ -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<String, dynamic> json) => UrlInfo(
host: json['host'] as String?,
extra: json['extra'] as String?,
host: json['host'] as String,
extra: json['extra'] as String,
);
}

View File

@@ -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<CodecItem> 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> stream;
int streamIndex = 0;
int formatIndex = 0;
int codecIndex = 0;
int liveUrlIndex = 0;
Future<void>? 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<void> 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(),
)

View File

@@ -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<LiveHeaderControl>
),
),
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<LiveHeaderControl>
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,
});
}

View File

@@ -64,4 +64,8 @@ extension ListExt<T> on List<T> {
if (index < 0 || index >= length) return null;
return this[index];
}
T getOrFirst(int index) {
return getOrNull(index) ?? first;
}
}

View File

@@ -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;
}
}