feat: dlna

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-11-12 19:02:40 +08:00
parent 0acd9ca767
commit 37b1228552
11 changed files with 257 additions and 41 deletions

View File

@@ -24,6 +24,8 @@ class Api {
static const String pugvUrl = '/pugv/player/web/playurl';
static const String tvPlayUrl = '/x/tv/playurl';
// 字幕
// aid, cid
static const String playInfo = '/x/player/wbi/v2';

View File

@@ -204,11 +204,7 @@ abstract final class LiveHttp {
'statistics': Constants.statisticsApp,
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
AppSign.appSign(
params,
Constants.appKey,
Constants.appSec,
);
AppSign.appSign(params);
var res = await Request().get(
Api.liveFeedIndex,
queryParameters: params,
@@ -290,11 +286,7 @@ abstract final class LiveHttp {
'statistics': Constants.statisticsApp,
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
AppSign.appSign(
params,
Constants.appKey,
Constants.appSec,
);
AppSign.appSign(params);
var res = await Request().get(
Api.liveSecondList,
queryParameters: params,
@@ -340,11 +332,7 @@ abstract final class LiveHttp {
'statistics': Constants.statisticsApp,
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
AppSign.appSign(
params,
Constants.appKey,
Constants.appSec,
);
AppSign.appSign(params);
var res = await Request().get(
Api.liveAreaList,
queryParameters: params,
@@ -377,11 +365,7 @@ abstract final class LiveHttp {
'statistics': Constants.statisticsApp,
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
AppSign.appSign(
params,
Constants.appKey,
Constants.appSec,
);
AppSign.appSign(params);
var res = await Request().get(
Api.getLiveFavTag,
queryParameters: params,
@@ -419,11 +403,7 @@ abstract final class LiveHttp {
'statistics': Constants.statisticsApp,
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
AppSign.appSign(
data,
Constants.appKey,
Constants.appSec,
);
AppSign.appSign(data);
var res = await Request().post(
Api.setLiveFavTag,
data: data,
@@ -459,11 +439,7 @@ abstract final class LiveHttp {
'statistics': Constants.statisticsApp,
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
AppSign.appSign(
params,
Constants.appKey,
Constants.appSec,
);
AppSign.appSign(params);
var res = await Request().get(
Api.liveRoomAreaList,
queryParameters: params,
@@ -502,11 +478,7 @@ abstract final class LiveHttp {
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000,
'type': type.name,
};
AppSign.appSign(
params,
Constants.appKey,
Constants.appSec,
);
AppSign.appSign(params);
var res = await Request().get(
Api.liveSearch,
queryParameters: params,

View File

@@ -26,6 +26,7 @@ import 'package:PiliPlus/models_new/video/video_note_list/data.dart';
import 'package:PiliPlus/models_new/video/video_play_info/data.dart';
import 'package:PiliPlus/models_new/video/video_relation/data.dart';
import 'package:PiliPlus/utils/accounts.dart';
import 'package:PiliPlus/utils/app_sign.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/global_data.dart';
import 'package:PiliPlus/utils/id_utils.dart';
@@ -1060,4 +1061,39 @@ class VideoHttp {
return Error(res.data['message']);
}
}
static Future<LoadingState<PlayUrlModel>> tvPlayUrl({
required int cid,
required int objectId, // aid, epid
required int playurlType, // ugc 1, pgc 2
int? qn,
}) async {
final accessKey = Accounts.accountMode[AccountType.video.index].accessKey;
final params = {
'access_key': ?accessKey,
'actionKey': 'appkey',
'appkey': Constants.appKey,
'cid': cid,
'fourk': 1,
'is_proj': 1,
'mobile_access_key': ?accessKey,
'object_id': objectId,
'mobi_app': 'android',
'platform': 'android',
'playurl_type': playurlType,
'protocol': 0,
'qn': qn ?? 80,
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
AppSign.appSign(params);
final res = await Request().get(
Api.tvPlayUrl,
queryParameters: params,
);
if (res.data['code'] == 0) {
return Success(PlayUrlModel.fromJson(res.data['data']));
} else {
return Error(res.data['message']);
}
}
}

130
lib/pages/dlna/view.dart Normal file
View File

@@ -0,0 +1,130 @@
import 'dart:async';
import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart';
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
import 'package:PiliPlus/common/widgets/view_sliver_safe_area.dart';
import 'package:dlna_dart/dlna.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class DLNAPage extends StatefulWidget {
const DLNAPage({super.key});
@override
State<DLNAPage> createState() => _DLNAPageState();
}
class _DLNAPageState extends State<DLNAPage> {
final _searcher = DLNAManager();
final Map<String, DLNADevice> _deviceList = {};
late final _url = Get.parameters['url']!;
late final _title = Get.parameters['title'];
Timer? _timer;
bool _isSearching = false;
DLNADevice? _lastDevice;
String? _lastDeviceKey;
@override
void initState() {
super.initState();
_onSearch(isInit: true);
}
Future<void> _onSearch({bool isInit = false}) async {
if (_isSearching) return;
_isSearching = true;
if (!isInit && mounted) {
_lastDevice = null;
_deviceList.clear();
setState(() {});
}
final deviceManager = await _searcher.start();
if (!mounted) {
return;
}
_timer = Timer(const Duration(seconds: 20), _searcher.stop);
await for (final deviceList in deviceManager.devices.stream) {
if (mounted) {
_deviceList.addAll(deviceList);
setState(() {});
}
}
if (mounted) {
setState(() {
_isSearching = false;
});
}
}
@override
void dispose() {
_timer?.cancel();
_timer = null;
_searcher.stop();
_lastDevice = null;
_lastDeviceKey = null;
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = ColorScheme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('投屏'),
actions: [
IconButton(
tooltip: '搜索',
onPressed: _onSearch,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 6),
],
),
body: CustomScrollView(
slivers: [
if (_isSearching) linearLoading,
ViewSliverSafeArea(sliver: _buildBody(colorScheme)),
],
),
);
}
Widget _buildBody(ColorScheme colorScheme) {
if (!_isSearching && _deviceList.isEmpty) {
return HttpError(
errMsg: '没有设备',
onReload: _onSearch,
);
}
if (_deviceList.isNotEmpty) {
final keys = _deviceList.keys.toList();
return SliverList.builder(
itemCount: keys.length,
itemBuilder: (context, index) {
final key = keys[index];
final device = _deviceList[key]!;
final isCurr = key == _lastDeviceKey;
return ListTile(
title: Text(
device.info.friendlyName,
style: isCurr ? TextStyle(color: colorScheme.primary) : null,
),
subtitle: Text(key),
onTap: () async {
if (isCurr) return;
_lastDevice?.pause();
_lastDevice = device;
_lastDeviceKey = key;
setState(() {});
await device.setUrl(_url, title: _title ?? '');
await device.play();
},
);
},
);
}
return const SliverToBoxAdapter();
}
}

View File

@@ -1991,4 +1991,50 @@ class VideoDetailController extends GetxController
),
);
}
Future<void> onCast() async {
SmartDialog.showLoading();
final res = await VideoHttp.tvPlayUrl(
cid: cid.value,
objectId: epId ?? aid,
playurlType: epId != null ? 2 : 1,
qn: currentVideoQa.value?.code,
);
SmartDialog.dismiss();
if (res.isSuccess) {
final PlayUrlModel data = res.data;
final first = data.durl?.firstOrNull;
final url = first?.backupUrl?.lastOrNull ?? first?.url;
if (url == null || url.isEmpty) {
SmartDialog.showToast('不支持投屏');
return;
}
String? title;
try {
if (isUgc) {
title = Get.find<UgcIntroController>(
tag: heroTag,
).videoDetail.value.title;
} else {
title = Get.find<PgcIntroController>(
tag: heroTag,
).videoDetail.value.title;
}
} catch (_) {
if (kDebugMode) rethrow;
}
if (kDebugMode) {
debugPrint(title);
}
Get.toNamed(
'/dlna',
parameters: {
'url': url,
'title': ?title,
},
);
} else {
res.toast();
}
}
}

View File

@@ -2413,23 +2413,41 @@ class HeaderControlState extends State<HeaderControl> {
},
),
if (!isFileSource) ...[
if ((!isFSOrPip && videoDetailCtr.isUgc))
if (!isFSOrPip) ...[
if (videoDetailCtr.isUgc)
SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '听音频',
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: videoDetailCtr.toAudioPage,
icon: const Icon(
Icons.headphones_outlined,
size: 19,
color: Colors.white,
),
),
),
SizedBox(
width: 42,
height: 34,
child: IconButton(
tooltip: '听音频',
tooltip: '投屏',
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: videoDetailCtr.toAudioPage,
onPressed: videoDetailCtr.onCast,
icon: const Icon(
Icons.headphones_outlined,
Icons.cast,
size: 19,
color: Colors.white,
),
),
),
],
if (plPlayerController.enableSponsorBlock == true)
SizedBox(
width: 42,

View File

@@ -4,6 +4,7 @@ import 'package:PiliPlus/pages/article_list/view.dart';
import 'package:PiliPlus/pages/audio/view.dart';
import 'package:PiliPlus/pages/blacklist/view.dart';
import 'package:PiliPlus/pages/danmaku_block/view.dart';
import 'package:PiliPlus/pages/dlna/view.dart';
import 'package:PiliPlus/pages/download/view.dart';
import 'package:PiliPlus/pages/dynamics/view.dart';
import 'package:PiliPlus/pages/dynamics_create_vote/view.dart';
@@ -229,6 +230,7 @@ class Routes {
CustomGetPage(name: '/followed', page: () => const FollowedPage()),
CustomGetPage(name: '/sameFollowing', page: () => const FollowSamePage()),
CustomGetPage(name: '/download', page: () => const DownloadPage()),
CustomGetPage(name: '/dlna', page: () => const DLNAPage()),
];
}

View File

@@ -5,10 +5,10 @@ import 'package:crypto/crypto.dart';
abstract class AppSign {
static void appSign(
Map<String, dynamic> params, [
Map<String, dynamic> params, {
String appkey = Constants.appKey,
String appsec = Constants.appSec,
]) {
}) {
params['appkey'] = appkey;
var searchParams = Uri(
queryParameters: params.map(