From 37b12285526b086b7557936318867333a30d1bd0 Mon Sep 17 00:00:00 2001 From: bggRGjQaUbCoE Date: Wed, 12 Nov 2025 19:02:40 +0800 Subject: [PATCH] feat: dlna Signed-off-by: bggRGjQaUbCoE --- README.md | 1 + lib/http/api.dart | 2 + lib/http/live.dart | 42 ++----- lib/http/video.dart | 36 ++++++ lib/pages/dlna/view.dart | 130 ++++++++++++++++++++ lib/pages/video/controller.dart | 46 +++++++ lib/pages/video/widgets/header_control.dart | 26 +++- lib/router/app_pages.dart | 2 + lib/utils/app_sign.dart | 4 +- pubspec.lock | 8 ++ pubspec.yaml | 1 + 11 files changed, 257 insertions(+), 41 deletions(-) create mode 100644 lib/pages/dlna/view.dart diff --git a/README.md b/README.md index b8652ae95..6a72cc075 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ ## feat +- [x] DLNA 投屏 - [x] 离线缓存/播放 - [x] 移动端支持点击弹幕悬停,点赞、复制、举报 by [@My-Responsitories](https://github.com/My-Responsitories) - [x] 播放音频 diff --git a/lib/http/api.dart b/lib/http/api.dart index ac82d1ae0..d71a3c94d 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -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'; diff --git a/lib/http/live.dart b/lib/http/live.dart index 442a585cf..cbf96d714 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -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, diff --git a/lib/http/video.dart b/lib/http/video.dart index 73acbf3a8..c25d4285c 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -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> 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']); + } + } } diff --git a/lib/pages/dlna/view.dart b/lib/pages/dlna/view.dart new file mode 100644 index 000000000..e2696d07f --- /dev/null +++ b/lib/pages/dlna/view.dart @@ -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 createState() => _DLNAPageState(); +} + +class _DLNAPageState extends State { + final _searcher = DLNAManager(); + final Map _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 _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(); + } +} diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index edae20c08..2056b102e 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -1991,4 +1991,50 @@ class VideoDetailController extends GetxController ), ); } + + Future 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( + tag: heroTag, + ).videoDetail.value.title; + } else { + title = Get.find( + tag: heroTag, + ).videoDetail.value.title; + } + } catch (_) { + if (kDebugMode) rethrow; + } + if (kDebugMode) { + debugPrint(title); + } + Get.toNamed( + '/dlna', + parameters: { + 'url': url, + 'title': ?title, + }, + ); + } else { + res.toast(); + } + } } diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index b4d2c886b..880c8aeb4 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -2413,23 +2413,41 @@ class HeaderControlState extends State { }, ), 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, diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 03ee66374..abeea3e3a 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -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()), ]; } diff --git a/lib/utils/app_sign.dart b/lib/utils/app_sign.dart index 95f349b0b..78fb45172 100644 --- a/lib/utils/app_sign.dart +++ b/lib/utils/app_sign.dart @@ -5,10 +5,10 @@ import 'package:crypto/crypto.dart'; abstract class AppSign { static void appSign( - Map params, [ + Map params, { String appkey = Constants.appKey, String appsec = Constants.appSec, - ]) { + }) { params['appkey'] = appkey; var searchParams = Uri( queryParameters: params.map( diff --git a/pubspec.lock b/pubspec.lock index fb6b89090..bad545b97 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -420,6 +420,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + dlna_dart: + dependency: "direct main" + description: + name: dlna_dart + sha256: "8a4f0e4f378615c99f2af679dc9f0c72fe4a0fb2f3eea96b637fe691dfcf0649" + url: "https://pub.dev" + source: hosted + version: "0.1.0" dynamic_color: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 966ae5f00..f7af17075 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -221,6 +221,7 @@ dependencies: url: https://github.com/bggRGjQaUbCoE/super_sliver_list.git ref: mod xml: ^6.6.1 + dlna_dart: ^0.1.0 vector_math: any fixnum: any