diff --git a/.github/workflows/pr171-fix-analyzer.yml b/.github/workflows/pr171-fix-analyzer.yml new file mode 100644 index 0000000000..6281bb02c8 --- /dev/null +++ b/.github/workflows/pr171-fix-analyzer.yml @@ -0,0 +1,61 @@ +name: PR171 fix analyzer errors +on: + push: + branches: [resolve/pr171-ci] +permissions: + contents: write +jobs: + fix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: resolve/pr171-ohos + fetch-depth: 0 + - name: Fix remaining semantic-port errors + shell: bash + run: | + set -eu + git config user.name "哥哥科技" + git config user.email "175325443+ucxn@users.noreply.github.com" + python3 - <<'PY' + from pathlib import Path + + # 1) Lifecycle merge: one extra brace closed the State class early. + p = Path('lib/plugin/pl_player/view/view.dart') + s = p.read_text() + bad = ''' if (state != AppLifecycleState.paused && state != AppLifecycleState.detached) {\n plPlayerController.autoResumeInPipIfNeeded();\n }\n }\n if (!plPlayerController.continuePlayInBackground.value) {''' + good = ''' if (state != AppLifecycleState.paused && state != AppLifecycleState.detached) {\n plPlayerController.autoResumeInPipIfNeeded();\n }\n if (!plPlayerController.continuePlayInBackground.value) {''' + if bad not in s: + raise SystemExit('lifecycle brace pattern not found') + p.write_text(s.replace(bad, good, 1)) + + # 2) OHOS added a serialized setDataSource wrapper. Carry liveUid through + # both wrapper -> private implementation layers for playback statistics. + p = Path('lib/plugin/pl_player/controller.dart') + s = p.read_text() + call = ''' pgcType: pgcType,\n videoType: videoType,''' + if call not in s: + raise SystemExit('setDataSource wrapper call anchor not found') + s = s.replace(call, ''' pgcType: pgcType,\n liveUid: liveUid,\n videoType: videoType,''', 1) + private_sig = ''' int? seasonId,\n int? pgcType,\n VideoType? videoType,''' + if private_sig not in s: + raise SystemExit('private _setDataSource signature anchor not found') + s = s.replace(private_sig, ''' int? seasonId,\n int? pgcType,\n int? liveUid,\n VideoType? videoType,''', 1) + p.write_text(s) + + # 3) This PR targets the OHOS fork. Keep the shared Windows probe API + # shape but do not import a win32 FFI implementation incompatible with + # the fork's pinned win32 version. Windows enhancement can be ported + # separately without blocking the playback-time feature. + Path('lib/utils/windows/network_info.dart').write_text('''final class WindowsNetworkInfo {\n const WindowsNetworkInfo({this.rssi, this.wifiMbps, this.wiredMbps});\n\n final int? rssi;\n final int? wifiMbps;\n final int? wiredMbps;\n}\n\nabstract final class WindowsNetworkInfoReader {\n static const _empty = WindowsNetworkInfo();\n\n static WindowsNetworkInfo read() => _empty;\n}\n''') + PY + + if grep -R -n -E '^(<<<<<<<|=======|>>>>>>>)' lib android; then + echo 'Conflict markers remain' >&2 + exit 1 + fi + git diff --check + git add lib/plugin/pl_player/view/view.dart lib/plugin/pl_player/controller.dart lib/utils/windows/network_info.dart + git commit -m "fix: adapt playback insights to OHOS runtime" + git push origin HEAD:resolve/pr171-ohos diff --git a/.github/workflows/pr171-validate.yml b/.github/workflows/pr171-validate.yml new file mode 100644 index 0000000000..149103d84c --- /dev/null +++ b/.github/workflows/pr171-validate.yml @@ -0,0 +1,22 @@ +name: PR171 validate resolved OHOS tree +on: + push: + branches: [resolve/pr171-ci] +permissions: + contents: read +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: resolve/pr171-ohos + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: "3.41.9" + cache: true + - run: flutter pub get + env: + GIT_LFS_SKIP_SMUDGE: 1 + - run: dart analyze --no-fatal-warnings diff --git a/android/app/src/main/java/com/example/piliplus/AndroidHelper.java b/android/app/src/main/java/com/example/piliplus/AndroidHelper.java index ebc2a15616..14554b4763 100644 --- a/android/app/src/main/java/com/example/piliplus/AndroidHelper.java +++ b/android/app/src/main/java/com/example/piliplus/AndroidHelper.java @@ -20,6 +20,10 @@ import android.graphics.drawable.Icon; import android.media.session.PlaybackState; import android.net.Uri; +import android.net.ConnectivityManager; +import android.net.NetworkCapabilities; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; import android.os.Build; import android.provider.MediaStore; import android.provider.Settings; @@ -64,6 +68,47 @@ public static int sdkInt() { return Build.VERSION.SDK_INT; } + @SuppressWarnings("deprecation") + public static int[] networkInfo() { + Context context = getContext(); + ConnectivityManager connectivityManager = context.getSystemService(ConnectivityManager.class); + NetworkCapabilities capabilities = connectivityManager == null + ? null + : connectivityManager.getNetworkCapabilities(connectivityManager.getActiveNetwork()); + if (capabilities == null || !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return null; + } + + int linkSpeed = -1; + int rssi = -127; + int signalLevel = -1; + WifiManager wifiManager = context.getSystemService(WifiManager.class); + if (wifiManager != null) { + WifiInfo wifiInfo = wifiManager.getConnectionInfo(); + if (wifiInfo != null) { + linkSpeed = wifiInfo.getLinkSpeed(); + rssi = wifiInfo.getRssi(); + if (rssi > -127 && rssi <= 0) { + signalLevel = WifiManager.calculateSignalLevel(rssi, 5); + } + } + } + + int flags = connectivityManager.isActiveNetworkMetered() ? 1 : 0; + if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)) { + flags |= 2; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + && !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { + flags |= 4; + } + if (Build.VERSION.SDK_INT >= 36 + && !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED)) { + flags |= 8; + } + return new int[]{linkSpeed, rssi, signalLevel, flags}; + } + public static void back() { Intent intent = new Intent(Intent.ACTION_MAIN); intent.addCategory(Intent.CATEGORY_HOME); diff --git a/lib/http/init.dart b/lib/http/init.dart index c94ed06f22..6eb81fe8fa 100644 --- a/lib/http/init.dart +++ b/lib/http/init.dart @@ -7,9 +7,11 @@ import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/retry_interceptor.dart'; import 'package:PiliPlus/http/user.dart'; +import 'package:PiliPlus/models/common/network_profile.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/accounts/account.dart'; import 'package:PiliPlus/utils/accounts/account_manager/account_mgr.dart'; +import 'package:PiliPlus/utils/connectivity_utils.dart'; import 'package:PiliPlus/utils/global_data.dart'; import 'package:PiliPlus/utils/login_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; @@ -249,7 +251,15 @@ class Request { return status != null && status >= 200 && status < 300; }; - if (Platform.isIOS) _watchConnectivity(); + if (Platform.isIOS) { + _watchConnectivity(); + } else { + ConnectivityUtils.changes.listen((change) { + if (change.reason == NetworkPolicyReason.network) { + _resetAdaptersForNetworkChange(); + } + }); + } } /* diff --git a/lib/main.dart b/lib/main.dart index f4f9df4d24..e588228b62 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,9 +16,11 @@ import 'package:PiliPlus/router/app_pages.dart'; import 'package:PiliPlus/services/account_service.dart'; import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/services/logger.dart'; +import 'package:PiliPlus/services/playback_stats_service.dart'; import 'package:PiliPlus/services/service_locator.dart'; import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/calc_window_position.dart'; +import 'package:PiliPlus/utils/connectivity_utils.dart'; import 'package:PiliPlus/utils/date_utils.dart'; import 'package:PiliPlus/utils/extension/core_palettes_ext.dart'; import 'package:PiliPlus/utils/extension/theme_ext.dart'; @@ -108,12 +110,14 @@ void main() async { if (kDebugMode) debugPrint('GStorage init error: $e'); exit(0); } + PlaybackStatsService.initializeAppLifecycle(); ScaledWidgetsFlutterBinding.instance.scaleFactor = Pref.uiScale; await Future.wait([ _initDownPath(), _initTmpPath(), CacheManager.ensureInitialized(), ]); + await ConnectivityUtils.initialize(); Get ..lazyPut(AccountService.new) ..lazyPut(DownloadService.new); diff --git a/lib/models/common/network_profile.dart b/lib/models/common/network_profile.dart new file mode 100644 index 0000000000..71a6a96a47 --- /dev/null +++ b/lib/models/common/network_profile.dart @@ -0,0 +1,42 @@ +enum NetworkTransport { + wifi('Wi-Fi'), + wired('有线网络'), + cellular('蜂窝网络'), + other('其他网络'); + + const NetworkTransport(this.label); + final String label; +} + +enum NetworkPolicyReason { network, peak } + +final class NetworkProfile { + const NetworkProfile({ + required this.transport, + required this.useCellularPreferences, + this.rssi, + this.linkSpeedMbps, + this.signalLevel, + this.metered = false, + this.weakHint = false, + }); + + final NetworkTransport transport; + final bool useCellularPreferences; + final int? rssi; + final int? linkSpeedMbps; + final int? signalLevel; + final bool metered; + final bool weakHint; + + String get preferenceLabel => useCellularPreferences ? '等效移网' : '等效宽带'; + + Object get identity => (transport, useCellularPreferences); +} + +final class NetworkPolicyChange { + const NetworkPolicyChange(this.profile, this.reason); + + final NetworkProfile profile; + final NetworkPolicyReason reason; +} diff --git a/lib/models/common/reply/reply_sort_type.dart b/lib/models/common/reply/reply_sort_type.dart index 6ee4486ce7..a308435aff 100644 --- a/lib/models/common/reply/reply_sort_type.dart +++ b/lib/models/common/reply/reply_sort_type.dart @@ -8,4 +8,6 @@ enum ReplySortType { final String label; final String? text; const ReplySortType(this.title, this.label, {this.text}); + + String get reply2Title => this == time ? '最早评论' : title; } diff --git a/lib/pages/audio/controller.dart b/lib/pages/audio/controller.dart index a0d2e97554..a58bc7bada 100644 --- a/lib/pages/audio/controller.dart +++ b/lib/pages/audio/controller.dart @@ -17,6 +17,7 @@ import 'package:PiliPlus/harmony_adapt/harmony_channel.dart'; import 'package:PiliPlus/http/browser_ua.dart'; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models/common/network_profile.dart'; import 'package:PiliPlus/pages/common/common_intro_controller.dart' show FavMixin; import 'package:PiliPlus/pages/dynamics_repost/view.dart'; @@ -83,6 +84,9 @@ class AudioController extends GetxController late final AnimationController animController; List? _subscriptions; + StreamSubscription? _networkPolicySubscription; + bool _queryingPlayUrl = false; + bool _pendingNetworkReload = false; int? index; List? playlist; @@ -178,8 +182,14 @@ class AudioController extends GetxController _querySponsorBlock(); _onOpenMedia(audioUrl, ua: BrowserUa.pc, referer: HttpString.baseUrl); } - ConnectivityUtils.isWiFi.then((isWiFi) { - cacheAudioQa = isWiFi ? Pref.defaultAudioQa : Pref.defaultAudioQaCellular; + ConnectivityUtils.resolveForPlayback().then((profile) { + if (isClosed) return; + cacheAudioQa = profile.useCellularPreferences + ? Pref.defaultAudioQaCellular + : Pref.defaultAudioQa; + _networkPolicySubscription = ConnectivityUtils.changes.listen( + _onNetworkPolicyChanged, + ); if (!hasAudioUrl) { _queryPlayUrl(); } @@ -304,23 +314,35 @@ class AudioController extends GetxController } } - Future _queryPlayUrl() async { - _querySponsorBlock(); - final res = await AudioGrpc.audioPlayUrl( - itemType: itemType, - oid: oid, - subId: subId, - ); - if (res case Success(:final response)) { - _onPlay(response); - return true; - } else { - res.toast(); + Future _queryPlayUrl({bool autoplay = true}) async { + if (_queryingPlayUrl) { return false; } + _queryingPlayUrl = true; + try { + _querySponsorBlock(); + final res = await AudioGrpc.audioPlayUrl( + itemType: itemType, + oid: oid, + subId: subId, + ); + if (res case Success(:final response)) { + await _onPlay(response, autoplay: autoplay); + return true; + } else { + res.toast(); + return false; + } + } finally { + _queryingPlayUrl = false; + if (_pendingNetworkReload) { + _pendingNetworkReload = false; + await _reloadForNetworkPolicy(); + } + } } - void _onPlay(PlayURLResp data) { + Future _onPlay(PlayURLResp data, {bool autoplay = true}) async { final PlayInfo? playInfo = data.playerInfo.values.firstOrNull; if (playInfo != null) { if (playInfo.hasPlayDash()) { @@ -329,12 +351,15 @@ class AudioController extends GetxController if (audios.isEmpty) { return; } - position.value = 0; + if (_start == null) position.value = 0; final audio = audios.findClosestTarget( (e) => e.id <= cacheAudioQa, (a, b) => a.id > b.id ? a : b, ); - _onOpenMedia(VideoUtils.getCdnUrl(audio.playUrls)); + await _onOpenMedia( + VideoUtils.getCdnUrl(audio.playUrls), + autoplay: autoplay, + ); } else if (playInfo.hasPlayUrl()) { final playUrl = playInfo.playUrl; final durls = playUrl.durl; @@ -342,8 +367,11 @@ class AudioController extends GetxController return; } final durl = durls.first; - position.value = 0; - _onOpenMedia(VideoUtils.getCdnUrl(durl.playUrls)); + if (_start == null) position.value = 0; + await _onOpenMedia( + VideoUtils.getCdnUrl(durl.playUrls), + autoplay: autoplay, + ); } } } @@ -352,23 +380,46 @@ class AudioController extends GetxController String url, { String ua = Constants.userAgentApp, String? referer, + bool? autoplay, }) async { await _initPlayerIfNeeded(); - player?.open( - Media( - url, - start: _start, - httpHeaders: { - 'user-agent': ua, - 'referer': ?referer, - }, - ), - play: _autoplayOnOpen, - ); + final player = this.player; + if (player != null) { + await player.open( + Media( + url, + start: _start, + httpHeaders: { + 'user-agent': ua, + 'referer': ?referer, + }, + ), + play: autoplay ?? _autoplayOnOpen, + ); + } _autoplayOnOpen = true; _start = null; } + Future _onNetworkPolicyChanged(NetworkPolicyChange change) async { + if (change.reason != NetworkPolicyReason.network || isClosed) return; + cacheAudioQa = change.profile.useCellularPreferences + ? Pref.defaultAudioQaCellular + : Pref.defaultAudioQa; + if (_queryingPlayUrl) { + _pendingNetworkReload = true; + return; + } + await _reloadForNetworkPolicy(); + } + + Future _reloadForNetworkPolicy() async { + final player = this.player; + if (player == null) return; + _start = player.state.position; + await _queryPlayUrl(autoplay: player.state.playing); + } + Future _initPlayerIfNeeded() async { if (_hasInit) return; _hasInit = true; @@ -845,6 +896,8 @@ class AudioController extends GetxController _subscriptions?.forEach((e) => e.cancel()); _subscriptions?.clear(); _subscriptions = null; + _networkPolicySubscription?.cancel(); + _networkPolicySubscription = null; player?.dispose(); player = null; animController.dispose(); diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index e620bca7d0..a032b88b60 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -8,6 +8,7 @@ import 'package:PiliPlus/harmony_adapt/harmony_channel.dart'; import 'package:PiliPlus/http/live.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/video.dart'; +import 'package:PiliPlus/models/common/network_profile.dart'; import 'package:PiliPlus/models/common/super_chat_type.dart'; import 'package:PiliPlus/models/common/video/live_quality.dart'; import 'package:PiliPlus/models/model_owner.dart'; @@ -25,17 +26,17 @@ import 'package:PiliPlus/pages/live_room/send_danmaku/view.dart'; import 'package:PiliPlus/pages/video/widgets/header_control.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/data_source.dart'; +import 'package:PiliPlus/plugin/pl_player/models/play_status.dart'; import 'package:PiliPlus/plugin/pl_player/utils/danmaku_options.dart'; +import 'package:PiliPlus/services/playback_stats_service.dart'; import 'package:PiliPlus/services/service_locator.dart'; import 'package:PiliPlus/tcp/live.dart'; import 'package:PiliPlus/utils/accounts.dart'; -import 'package:PiliPlus/utils/connectivity_utils.dart'; import 'package:PiliPlus/utils/danmaku_utils.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/extension/iterable_ext.dart'; import 'package:PiliPlus/utils/global_data.dart'; import 'package:PiliPlus/utils/num_utils.dart'; -import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/theme_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; @@ -115,7 +116,7 @@ class LiveRoomController extends GetxController { late final RxInt pageIndex = 0.obs; PageController? pageController; - int? currentQn = PlatformUtils.isMobile ? null : Pref.liveQuality; + int? currentQn; final currentQnDesc = ''.obs; final RxBool isPortrait = false.obs; late List<({int code, String desc})> acceptQnList = []; @@ -151,6 +152,11 @@ class LiveRoomController extends GetxController { }); StreamSubscription? _sizeSub; + NetworkProfile? _networkProfile; + bool _queryingLiveUrl = false; + bool _pendingNetworkReload = false; + late final Future Function(NetworkPolicyChange)? + _previousNetworkPolicyCallback; void _onSizeChanged((int, int) value) { final isVertical = value.$2 > value.$1; @@ -183,10 +189,11 @@ class LiveRoomController extends GetxController { isLogin = account.isLogin; mid = account.mid; HarmonyChannel.holdContinuation(this); - // 跨设备接续等场景显式指定初始状态(听直播/暂停),优先于默认行为 if (Get.parameters['onlyAudio'] == 'true') { plPlayerController.onlyPlayAudio.value = true; } + _previousNetworkPolicyCallback = plPlayerController.onNetworkPolicyChanged; + plPlayerController.onNetworkPolicyChanged = _onNetworkPolicyChanged; queryLiveUrl( autoplay: Get.parameters['autoplay'] != 'false', autoFullScreenFlag: true, @@ -210,6 +217,7 @@ class LiveRoomController extends GetxController { return plPlayerController.setDataSource( NetworkSource(videoSource: videoUrl!, audioSource: null), isLive: true, + liveUid: ruid, autoplay: autoplay, isVertical: isPortrait.value, autoFullScreenFlag: autoFullScreenFlag, @@ -217,50 +225,94 @@ class LiveRoomController extends GetxController { } Future queryLiveUrl({ - bool autoplay = true, bool autoFullScreenFlag = false, + bool autoplay = true, }) async { - currentQn ??= await ConnectivityUtils.isWiFi - ? Pref.liveQuality - : Pref.liveQualityCellular; - final res = await LiveHttp.liveRoomInfo( - roomId: roomId, - qn: currentQn, - onlyAudio: plPlayerController.onlyPlayAudio.value, - ); - if (res case Success(:final response)) { - if (response.liveStatus != 1) { - _showDialog('当前直播间未开播'); - return; + if (_queryingLiveUrl) return; + _queryingLiveUrl = true; + try { + final profile = await plPlayerController.resolveNetworkProfile(); + final previous = _networkProfile; + _networkProfile = profile; + plPlayerController.applyNetworkProfile( + profile, + resetSessionPreferences: + plPlayerController.cacheVideoQa == null || + previous?.identity != profile.identity, + ); + if (currentQn == null || previous?.identity != profile.identity) { + currentQn = profile.useCellularPreferences + ? Pref.liveQualityCellular + : Pref.liveQuality; } - final playurl = response.playurlInfo?.playurl; - if (playurl == null) { - _showDialog('无法获取播放地址'); - return; + + final res = await LiveHttp.liveRoomInfo( + roomId: roomId, + qn: currentQn, + onlyAudio: plPlayerController.onlyPlayAudio.value, + ); + if (res case Success(:final response)) { + if (response.liveStatus != 1) { + _showDialog('当前直播间未开播'); + return; + } + final playurl = response.playurlInfo?.playurl; + if (playurl == null) { + _showDialog('无法获取播放地址'); + return; + } + ruid = response.uid; + if (response.roomId case final roomId?) { + this.roomId = roomId; + } + liveTime.value = response.liveTime; + startLiveTimer(); + isPortrait.value = response.isPortrait ?? false; + stream = playurl.stream; + _initStreamIndex(); + await initLiveUrl( + streamIndex: streamIndex, + formatIndex: formatIndex, + codecIndex: codecIndex, + liveUrlIndex: liveUrlIndex, + autoplay: autoplay, + autoFullScreenFlag: autoFullScreenFlag, + ); + isLoaded.value = true; + } else { + _showDialog(res.toString()); } - ruid = response.uid; - if (response.roomId case final roomId?) { - this.roomId = roomId; + } finally { + _queryingLiveUrl = false; + if (_pendingNetworkReload) { + _pendingNetworkReload = false; + await _reloadForNetworkPolicy(); } - liveTime.value = response.liveTime; - startLiveTimer(); - isPortrait.value = response.isPortrait ?? false; - stream = playurl.stream; - _initStreamIndex(); - await initLiveUrl( - streamIndex: streamIndex, - formatIndex: formatIndex, - codecIndex: codecIndex, - liveUrlIndex: liveUrlIndex, - autoplay: autoplay, - autoFullScreenFlag: autoFullScreenFlag, - ); - isLoaded.value = true; - } else { - _showDialog(res.toString()); } } + Future _onNetworkPolicyChanged(NetworkPolicyChange change) async { + if (plPlayerController.isResolvingNetworkProfile) return; + plPlayerController.applyNetworkProfile( + change.profile, + resetSessionPreferences: change.reason == NetworkPolicyReason.network, + refreshPeakPreferences: change.reason == NetworkPolicyReason.peak, + ); + _networkProfile = change.profile; + if (change.reason != NetworkPolicyReason.network) return; + currentQn = change.profile.useCellularPreferences + ? Pref.liveQualityCellular + : Pref.liveQuality; + if (_queryingLiveUrl) { + _pendingNetworkReload = true; + return; + } + await _reloadForNetworkPolicy(); + } + + Future _reloadForNetworkPolicy() => + queryLiveUrl(autoplay: plPlayerController.playerStatus.isPlaying); + late List stream; int streamIndex = 0; int formatIndex = 0; @@ -483,6 +535,12 @@ class LiveRoomController extends GetxController { @override void onClose() { HarmonyChannel.releaseContinuation(this); + PlaybackStatsService.endMediaIfMatches( + isLive: true, + liveUid: ruid, + position: plPlayerController.videoPlayerController?.state.position ?? Duration.zero, + ); + plPlayerController.onNetworkPolicyChanged = _previousNetworkPolicyCallback; _stopSizeSub(); closeLiveMsg(); cancelLikeTimer(); diff --git a/lib/pages/live_room/widgets/chat_panel.dart b/lib/pages/live_room/widgets/chat_panel.dart index 16fea8be64..ca9381d54e 100644 --- a/lib/pages/live_room/widgets/chat_panel.dart +++ b/lib/pages/live_room/widgets/chat_panel.dart @@ -41,7 +41,6 @@ class LiveRoomChatPanel extends StatelessWidget { late final nameColor = isPP ? Colors.white.withValues(alpha: 0.9) : Colors.white.withValues(alpha: 0.6); - late final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); late final colorScheme = ColorScheme.of(context); late final primary = colorScheme.isDark ? colorScheme.primary @@ -119,7 +118,7 @@ class LiveRoomChatPanel extends StatelessWidget { ..onTap = () => Get.toNamed('/member?mid=${reply.mid}'), ), - _buildMsg(devicePixelRatio, item), + _buildMsg(item), ], ), ), @@ -233,16 +232,16 @@ class LiveRoomChatPanel extends StatelessWidget { ); } - InlineSpan _buildMsg(double devicePixelRatio, DanmakuMsg obj) { + InlineSpan _buildMsg(DanmakuMsg obj) { final uemote = obj.uemote; if (uemote != null) { // "room_{{room_id}}_{{int}}" , "upower_[{{emote}}]" , "official_{{int}}" final double width, height; if (uemote.isOfficial) { - width = uemote.width / devicePixelRatio; - height = uemote.height / devicePixelRatio; + width = uemote.width / 3; + height = uemote.height / 3; } else { - width = height = 162.0 / devicePixelRatio; + width = height = 54; } return WidgetSpan( child: NetworkImgLayer( diff --git a/lib/pages/setting/models/extra_settings.dart b/lib/pages/setting/models/extra_settings.dart index d62e973daa..834905205c 100644 --- a/lib/pages/setting/models/extra_settings.dart +++ b/lib/pages/setting/models/extra_settings.dart @@ -561,7 +561,7 @@ List get extraSettings => [ NormalModel( title: '二级评论展示', leading: const Icon(Icons.subdirectory_arrow_right_outlined), - getSubtitle: () => '当前优先展示「${Pref.reply2SortType.title}」', + getSubtitle: () => '当前优先展示「${Pref.reply2SortType.reply2Title}」', onTap: _showReply2SortDialog, ), NormalModel( @@ -1118,7 +1118,10 @@ Future _showReply2SortDialog( builder: (context) => SelectDialog( title: '二级评论展示', value: Pref.reply2SortType, - values: ReplySortType.values.take(2).map((e) => (e, e.title)).toList(), + values: ReplySortType.values + .take(2) + .map((e) => (e, e.reply2Title)) + .toList(), ), ); if (res != null) { diff --git a/lib/pages/setting/models/video_settings.dart b/lib/pages/setting/models/video_settings.dart index 8d8b4bbecf..3135ea21b3 100644 --- a/lib/pages/setting/models/video_settings.dart +++ b/lib/pages/setting/models/video_settings.dart @@ -138,6 +138,12 @@ List get videoSettings => [ '首选解码格式:${(Pref.preferCodecsCellular.map((i) => i.name).join(","))},请根据设备支持情况与需求调整', onTap: _showCellularCodecsDialog, ), + NormalModel( + title: 'PC 网络状态联动判断', + subtitle: '按有线或 Wi-Fi 状态联动画质、音质与编码偏好,并可设置网络高峰期', + leading: const Icon(Icons.lan_outlined), + onTap: (_, _) => Get.toNamed('/networkPolicy'), + ), if (kDebugMode || Platform.isAndroid) NormalModel( title: '音频输出设备', diff --git a/lib/pages/setting/pages/network_policy.dart b/lib/pages/setting/pages/network_policy.dart new file mode 100644 index 0000000000..1797b5d034 --- /dev/null +++ b/lib/pages/setting/pages/network_policy.dart @@ -0,0 +1,535 @@ +import 'package:PiliPlus/common/widgets/flutter/list_tile.dart'; +import 'package:PiliPlus/common/widgets/scaffold/simple_scaffold.dart'; +import 'package:PiliPlus/common/widgets/time_picker.dart' as pili; +import 'package:PiliPlus/common/widgets/view_safe_area.dart'; +import 'package:PiliPlus/models/common/network_profile.dart'; +import 'package:PiliPlus/models/common/video/video_decode_type.dart'; +import 'package:PiliPlus/pages/setting/widgets/ordered_multi_select_dialog.dart'; +import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart'; +import 'package:PiliPlus/pages/setting/widgets/switch_item.dart'; +import 'package:PiliPlus/utils/connectivity_utils.dart'; +import 'package:PiliPlus/utils/storage.dart'; +import 'package:PiliPlus/utils/storage_key.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:flutter/services.dart' show FilteringTextInputFormatter; +import 'package:get/get.dart'; +import 'package:material_ui/material_ui.dart' hide ListTile; + +class NetworkPolicyPage extends StatefulWidget { + const NetworkPolicyPage({super.key}); + + @override + State createState() => _NetworkPolicyPageState(); +} + +class _NetworkPolicyPageState extends State { + late bool wiredPolicy = Pref.wiredNetworkPolicy; + late bool wifiPolicy = Pref.wifiNetworkPolicy; + late int wiredSpeed = Pref.wiredMinLinkSpeed; + late int wifiMode = Pref.wifiNetworkPolicyMode; + late int rssi = Pref.wifiRssiThreshold; + late int wifiSpeed = Pref.wifiMinLinkSpeed; + NetworkProfile? profile = ConnectivityUtils.current; + + @override + void initState() { + super.initState(); + _refreshStatus(); + } + + Future _refreshStatus() async { + final value = await ConnectivityUtils.resolveForPlayback(); + if (mounted) setState(() => profile = value); + } + + Future _put(String key, Object value) async { + await GStorage.setting.put(key, value); + await ConnectivityUtils.notifySettingsChanged(); + if (mounted) setState(() {}); + } + + Future _inputInt({ + required String title, + required int value, + String? suffix, + bool signed = false, + }) { + var text = value.toString(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextFormField( + autofocus: true, + initialValue: text, + keyboardType: TextInputType.numberWithOptions(signed: signed), + inputFormatters: signed + ? [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))] + : [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + suffixText: suffix, + border: const OutlineInputBorder(borderRadius: .all(.circular(6))), + ), + onChanged: (value) => text = value, + ), + actions: [ + TextButton( + onPressed: Get.back, + child: Text( + '取消', + style: TextStyle(color: ColorScheme.of(context).outline), + ), + ), + TextButton( + onPressed: () { + final parsed = int.tryParse(text); + if (parsed == null || + parsed < (signed ? -100 : 0) || + signed && parsed > 0) { + SmartDialog.showToast('请输入有效数值'); + return; + } + Get.back(result: parsed); + }, + child: const Text('确定'), + ), + ], + ), + ); + } + + Future _setWiredSpeed() async { + var value = await showDialog( + context: context, + builder: (context) => SelectDialog( + title: '有线协商速率阈值', + value: {100, 1000, 2500}.contains(wiredSpeed) ? wiredSpeed : -1, + values: const [ + (100, '百兆(不低于 100 Mbps)'), + (1000, '千兆(不低于 1000 Mbps)'), + (2500, '2.5G(不低于 2500 Mbps)'), + (-1, '自定义'), + ], + ), + ); + if (value == -1 && mounted) { + value = await _inputInt( + title: '自定义有线速率阈值', + value: wiredSpeed, + suffix: 'Mbps', + ); + } + if (value != null && value > 0) { + wiredSpeed = value; + await _put(SettingBoxKey.wiredMinLinkSpeed, value); + } + } + + Future _showHelp() { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('功能详细说明'), + content: const SingleChildScrollView( + child: Text( + '电脑连接的是有线还是 Wi-Fi,只能说明接入方式,未必能说明这条网络现在是否适合高码率播放。这里可以根据链路协商速率和 Wi-Fi 状态,把当前连接判断为“等效宽带”或“等效移网”,并使用对应的画质、音质和编码偏好。\n\n' + '这看起来相似,却可能差很多:千兆有线通常比较稳定,降到百兆时可能意味着链路协商出现了变化,也可能只是身处校园网或其他共享网络;Wi-Fi 的信号强度和协商速率,则更能说明这一刻的无线连接是否适合高码率播放。USB 网络共享、手机热点等连接,也可能披着“电脑网络”的外观出现。这项功能用于让电脑在网络条件可能变差时,临时沿用蜂窝网络下的画质、音质和编码偏好。\n\n' + '这项功能把最终判断归纳为“等效宽带”和“等效移网”。等效宽带使用普通网络下的画质、音质和编码偏好,等效移网使用移动网络下的对应配置。用户可以自行决定哪些有线或 Wi-Fi 状态需要切换,不必受设备类型限制。\n\n' + '判断只读取当前连接类型、链路协商速率和 Wi-Fi 信号强度,不保存网络名称、IP 地址等无关信息。检测只发生在启动应用、进入播放器等关键节点,不会在播放过程中反复扫描,也不会因为信号的轻微波动频繁切换。所有门限均由用户自行设置,PiliPlus 只负责按照这些规则选择对应的播放配置。\n\n' + '到了网络高峰期,部分地区的宽带、校园网或特定 CDN 路径可能突然变慢。网络高峰期可以在指定时间段临时使用另一套编码偏好。当前视频保持原样,下一次选择视频流时再应用新的偏好;临时状态只存在于本次运行中,不会改写长期配置,也不会公网测速。\n\n' + '按流量计费的 Wi-Fi 会直接沿用蜂窝网络偏好。\n\n' + '系统多做一次判断,只需要一瞬间;人们少遇到一次卡顿,省下的却是真实的时间。人的时间永远比机器值钱。愿这一点小小的自动化,能让每一次播放少一点等待,多一点顺畅。\n\n' + '思路设计:哥哥科技\n\n\n' + 'PiliPlus,乾杯- ( ゜- ゜)つロ\n' + '祝大家永远网络通畅,冲浪愉快。', + ), + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('关闭')), + TextButton( + onPressed: () { + Get + ..back() + ..toNamed('/member?mid=501430041'); + }, + child: const Text('访问主页'), + ), + ], + ), + ); + } + + String get _status { + final value = profile; + if (value == null) return '正在读取当前网络状态'; + final details = [ + value.transport.label, + if (value.rssi case final rssi?) '$rssi dBm', + if (value.signalLevel case final level?) '信号等级 $level/4', + if (value.linkSpeedMbps case final speed?) '$speed Mbps', + if (value.metered) '按流量计费', + if (value.weakHint) '系统报告网络受限', + value.preferenceLabel, + ]; + return '当前:${details.join(' · ')}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SimpleScaffold( + appBar: AppBar(title: const Text('PC 网络状态联动判断')), + body: ViewSafeArea( + child: ListView( + padding: .only(bottom: MediaQuery.viewPaddingOf(context).bottom + 40), + children: [ + Padding( + padding: const .fromLTRB(16, 12, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + '按当前连接质量选择更合适的画质、音质与编码偏好。检测只在进入播放器或网络连接变化时进行,避免频繁抖动。Android 设备可能只支持其中一部分判断。', + style: TextStyle(color: theme.colorScheme.outline), + ), + ), + IconButton( + tooltip: '功能详细说明', + onPressed: _showHelp, + icon: const Icon(Icons.help_outline, size: 20), + ), + ], + ), + ), + ListTile( + title: const Text('当前网络状态'), + subtitle: Text(_status), + trailing: IconButton( + tooltip: '重新检测', + onPressed: _refreshStatus, + icon: const Icon(Icons.refresh), + ), + ), + const Divider(), + SetSwitchItem( + title: '电脑有线状态判断', + subtitle: '开启后,可按协商速率判断是否沿用蜂窝网络偏好', + setKey: SettingBoxKey.wiredNetworkPolicy, + defaultVal: wiredPolicy, + onChanged: (value) { + wiredPolicy = value; + ConnectivityUtils.notifySettingsChanged(); + setState(() {}); + }, + ), + if (wiredPolicy) ...[ + ListTile( + title: const Text('速率判断'), + subtitle: Text('协商速率低于 $wiredSpeed Mbps 时按蜂窝网络处理'), + onTap: _setWiredSpeed, + trailing: const Icon(Icons.chevron_right), + ), + SetSwitchItem( + title: '非标准速率时按蜂窝网络处理', + subtitle: '用于识别部分手机共享、异常协商或特殊网卡连接', + setKey: SettingBoxKey.wiredNonstandardLinkSpeed, + defaultVal: Pref.wiredNonstandardLinkSpeed, + onChanged: (_) => ConnectivityUtils.notifySettingsChanged(), + ), + ], + const Divider(), + SetSwitchItem( + title: '电脑 Wi-Fi 状况判断', + subtitle: '开启后,按下方条件判断是否沿用蜂窝网络偏好', + setKey: SettingBoxKey.wifiNetworkPolicy, + defaultVal: wifiPolicy, + onChanged: (value) { + wifiPolicy = value; + ConnectivityUtils.notifySettingsChanged(); + setState(() {}); + }, + ), + if (wifiPolicy) ...[ + ListTile( + title: const Text('判断方式'), + subtitle: Text( + const ['仅使用信号判断', '仅使用协商速率判断', '两者同时满足', '两者任一满足'][wifiMode], + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final value = await showDialog( + context: context, + builder: (context) => SelectDialog( + title: 'Wi-Fi 判断方式', + value: wifiMode, + values: const [ + (0, '仅使用信号判断'), + (1, '仅使用协商速率判断'), + (2, '信号和速率同时满足'), + (3, '信号或速率任一满足'), + ], + ), + ); + if (value != null) { + wifiMode = value; + await _put(SettingBoxKey.wifiNetworkPolicyMode, value); + } + }, + ), + ListTile( + title: const Text('Wi-Fi RSSI 阈值'), + subtitle: Text( + rssi == 0 + ? '0:Windows 下只要使用 Wi-Fi 即按蜂窝网络处理;Android 下仅在系统明确报告弱网络或播放持续卡住时处理' + : '信号低于 $rssi dBm 时按蜂窝网络处理;Android 读不到精准 RSSI 时不参与此项判断', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final value = await _inputInt( + title: 'Wi-Fi RSSI 阈值', + value: rssi, + suffix: 'dBm', + signed: true, + ); + if (value != null) { + rssi = value; + await _put(SettingBoxKey.wifiRssiThreshold, value); + } + }, + ), + ListTile( + title: const Text('Wi-Fi 协商速率阈值'), + subtitle: Text('协商速率低于 $wifiSpeed Mbps 时按蜂窝网络处理'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final value = await _inputInt( + title: 'Wi-Fi 协商速率阈值', + value: wifiSpeed, + suffix: 'Mbps', + ); + if (value != null) { + wifiSpeed = value; + await _put(SettingBoxKey.wifiMinLinkSpeed, value); + } + }, + ), + ], + const Divider(), + ListTile( + leading: const Icon(Icons.schedule_outlined), + title: const Text('网络高峰期'), + subtitle: const Text('按时段临时覆盖编码偏好;支持增加、删除和单独停用条目'), + trailing: const Icon(Icons.chevron_right), + onTap: () => showDialog( + context: context, + builder: (context) => const _NetworkPeakDialog(), + ).whenComplete(_refreshStatus), + ), + ], + ), + ), + ); + } +} + +class _NetworkPeakDialog extends StatefulWidget { + const _NetworkPeakDialog(); + + @override + State<_NetworkPeakDialog> createState() => _NetworkPeakDialogState(); +} + +class _NetworkPeakDialogState extends State<_NetworkPeakDialog> { + late List> periods = List.of(Pref.networkPeakPeriods); + + Future _save() async { + await GStorage.setting.put(SettingBoxKey.networkPeakPeriods, periods); + await ConnectivityUtils.notifySettingsChanged(); + } + + String _time(int minute) => + '${(minute ~/ 60).toString().padLeft(2, '0')}:${(minute % 60).toString().padLeft(2, '0')}'; + + Future _setTime(int index, String key) async { + final value = periods[index][key] as int; + final time = await pili.showTimePicker( + context: context, + initialTime: TimeOfDay(hour: value ~/ 60, minute: value % 60), + ); + if (time != null) { + periods[index][key] = time.hour * 60 + time.minute; + await _save(); + if (mounted) setState(() {}); + } + } + + Future _setCodecs() async { + final value = await showDialog>( + context: context, + builder: (context) => OrderedMultiSelectDialog( + title: '网络高峰期编码偏好', + initValues: Pref.networkPeakCodecs, + values: { + for (final codec in VideoDecodeFormatType.values) codec: codec.name, + }, + ), + ); + if (value != null) { + await GStorage.setting.put( + SettingBoxKey.networkPeakCodecs, + value.map((codec) => codec.name).toList(), + ); + await ConnectivityUtils.notifySettingsChanged(); + if (mounted) setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final codecs = Pref.networkPeakCodecs; + return Dialog( + clipBehavior: Clip.hardEdge, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640, maxHeight: 620), + child: Column( + children: [ + Padding( + padding: const .fromLTRB(20, 18, 8, 8), + child: Row( + children: [ + const Expanded( + child: Text('网络高峰期', style: TextStyle(fontSize: 20)), + ), + IconButton( + tooltip: '关闭', + onPressed: Get.back, + icon: const Icon(Icons.close), + ), + ], + ), + ), + ListTile( + title: const Text('高峰期编码偏好'), + subtitle: Text( + codecs.isEmpty + ? '未单独设置:跟随蜂窝网络首选解码格式' + : codecs.map((codec) => codec.name).join('、'), + ), + trailing: const Icon(Icons.chevron_right), + onTap: _setCodecs, + ), + const Divider(height: 1), + Expanded( + child: periods.isEmpty + ? const Center(child: Text('尚未添加高峰时段')) + : ListView.builder( + itemCount: periods.length, + itemBuilder: (context, index) { + final period = periods[index]; + return Card( + margin: const .fromLTRB(12, 8, 12, 0), + child: Padding( + padding: const .symmetric(vertical: 6), + child: Column( + children: [ + SwitchListTile( + dense: true, + title: Text('时段 ${index + 1}'), + value: period['enabled'] == true, + onChanged: (value) { + period['enabled'] = value; + _save(); + setState(() {}); + }, + ), + Row( + children: [ + Expanded( + child: ListTile( + dense: true, + title: const Text('开始'), + subtitle: Text(_time(period['start'])), + onTap: () => _setTime(index, 'start'), + ), + ), + Expanded( + child: ListTile( + dense: true, + title: const Text('结束'), + subtitle: Text(_time(period['end'])), + onTap: () => _setTime(index, 'end'), + ), + ), + ], + ), + ListTile( + dense: true, + title: const Text('生效范围'), + trailing: DropdownButton( + value: period['scope'] as int, + items: const [ + DropdownMenuItem( + value: 0, + child: Text('仅等效宽带'), + ), + DropdownMenuItem( + value: 1, + child: Text('全场景网络'), + ), + ], + onChanged: (value) { + if (value == null) return; + period['scope'] = value; + _save(); + setState(() {}); + }, + ), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () { + periods.removeAt(index); + _save(); + setState(() {}); + }, + icon: const Icon(Icons.delete_outline), + label: const Text('删除'), + ), + ), + ], + ), + ), + ); + }, + ), + ), + Padding( + padding: const .fromLTRB(16, 8, 16, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + periods.add({ + 'enabled': true, + 'start': 19 * 60, + 'end': 23 * 60, + 'scope': 0, + }); + _save(); + setState(() {}); + }, + icon: const Icon(Icons.add), + label: const Text('增加条目'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/setting/pages/play_speed_set.dart b/lib/pages/setting/pages/play_speed_set.dart index 0c516323e6..99907b760b 100644 --- a/lib/pages/setting/pages/play_speed_set.dart +++ b/lib/pages/setting/pages/play_speed_set.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:PiliPlus/common/widgets/flutter/list_tile.dart'; import 'package:PiliPlus/common/widgets/view_safe_area.dart'; import 'package:PiliPlus/pages/setting/widgets/switch_item.dart'; +import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/utils/extension/context_ext.dart'; import 'package:PiliPlus/utils/filtering_text.dart'; import 'package:PiliPlus/utils/storage.dart'; @@ -26,6 +27,8 @@ class _PlaySpeedPageState extends State { late double longPressSpeedDefault = Pref.longPressSpeedDefault; late List speedList = Pref.speedList; late bool enableAutoLongPressSpeed = Pref.enableAutoLongPressSpeed; + late double longPressSpeedFactor = Pref.longPressSpeedFactor; + late bool enableLongPressSlideSpeed = Pref.enableLongPressSlideSpeed; List<({int id, String title, Icon icon})> sheetMenu = [ ( id: 1, @@ -55,6 +58,53 @@ class _PlaySpeedPageState extends State { Box video = GStorage.video; + void _updateLongPressSpeedFactor(double value) { + longPressSpeedFactor = value; + PlPlayerController.instance?.longPressSpeedFactor = value; + if (mounted) setState(() {}); + } + + Future _saveLongPressSpeedFactor(double value) async { + _updateLongPressSpeedFactor(value); + await GStorage.setting.put(SettingBoxKey.longPressSpeedFactor, value); + } + + Future _inputLongPressSpeedFactor() async { + var value = longPressSpeedFactor.toString(); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('自定义长按倍速系数'), + content: TextFormField( + autofocus: true, + initialValue: value, + keyboardType: const .numberWithOptions(decimal: true), + inputFormatters: FilteringText.decimal, + decoration: const InputDecoration( + suffixText: '倍', + border: OutlineInputBorder(borderRadius: .all(.circular(6))), + ), + onChanged: (text) => value = text, + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('取消')), + TextButton( + onPressed: () { + final parsed = double.tryParse(value); + if (parsed == null || parsed <= 0) { + SmartDialog.showToast('请输入大于 0 的数值'); + return; + } + Get.back(result: parsed); + }, + child: const Text('确定'), + ), + ], + ), + ); + if (result != null) await _saveLongPressSpeedFactor(result); + } + // 添加自定义倍速 void onAddSpeed() { String initialValue = ''; @@ -218,17 +268,59 @@ class _PlaySpeedPageState extends State { ), SetSwitchItem( title: '动态长按倍速', - subtitle: '根据默认倍速长按时自动双倍', + subtitle: '长按时使用当前倍速乘以自定义系数', setKey: SettingBoxKey.enableAutoLongPressSpeed, defaultVal: enableAutoLongPressSpeed, - onChanged: (val) => - setState(() => enableAutoLongPressSpeed = val), + onChanged: (val) { + enableAutoLongPressSpeed = val; + PlPlayerController.instance?.enableAutoLongPressSpeed = val; + setState(() {}); + }, ), + if (enableAutoLongPressSpeed) ...[ + ListTile( + title: const Text('长按倍速系数'), + subtitle: Text( + '$longPressSpeedFactor 倍(${(longPressSpeedFactor * 100).round()}%)', + ), + trailing: TextButton( + onPressed: _inputLongPressSpeedFactor, + child: const Text('手动输入'), + ), + ), + Slider( + value: longPressSpeedFactor.clamp(0.5, 3.0), + min: 0.5, + max: 3, + divisions: 10, + label: + '${(longPressSpeedFactor.clamp(0.5, 3.0) * 100).round()}%', + onChanged: _updateLongPressSpeedFactor, + onChangeEnd: _saveLongPressSpeedFactor, + ), + ], if (!enableAutoLongPressSpeed) ListTile( title: const Text('默认长按倍速'), subtitle: Text(longPressSpeedDefault.toString()), ), + SetSwitchItem( + title: '长按时上下滑动调整临时倍速', + subtitle: '向上加速、向下减速,每档 0.25 倍;松手后恢复原倍速', + setKey: SettingBoxKey.enableLongPressSlideSpeed, + defaultVal: enableLongPressSlideSpeed, + onChanged: (value) { + enableLongPressSlideSpeed = value; + PlPlayerController.instance?.enableLongPressSlideSpeed = value; + }, + ), + ListTile( + leading: const Icon(Icons.insights_outlined), + title: const Text('倍速时间统计'), + subtitle: const Text('查看最常用倍速、实际节约时间、倒带回看与高级参数'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Get.toNamed('/playbackStats'), + ), Padding( padding: const EdgeInsets.only( left: 14, diff --git a/lib/pages/setting/pages/playback_stats.dart b/lib/pages/setting/pages/playback_stats.dart new file mode 100644 index 0000000000..f19793b1ff --- /dev/null +++ b/lib/pages/setting/pages/playback_stats.dart @@ -0,0 +1,217 @@ +import 'package:PiliPlus/common/widgets/flutter/list_tile.dart'; +import 'package:PiliPlus/common/widgets/scaffold/simple_scaffold.dart'; +import 'package:PiliPlus/common/widgets/view_safe_area.dart'; +import 'package:PiliPlus/services/playback_stats_service.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:material_ui/material_ui.dart' hide ListTile; + +class PlaybackStatsPage extends StatefulWidget { + const PlaybackStatsPage({super.key}); + + @override + State createState() => _PlaybackStatsPageState(); +} + +class _PlaybackStatsPageState extends State { + late Map stats = PlaybackStatsService.snapshot(); + + num _value(String key) => stats[key] as num? ?? 0; + Map get _derived => stats['derived'] as Map; + + String _duration(num microseconds) { + final negative = microseconds < 0; + var seconds = (microseconds.abs() / 1000000).round(); + final days = seconds ~/ 86400; + seconds %= 86400; + final hours = seconds ~/ 3600; + seconds %= 3600; + final minutes = seconds ~/ 60; + seconds %= 60; + final parts = [ + if (days > 0) '$days天', + if (hours > 0) '$hours小时', + if (minutes > 0) '$minutes分钟', + if (seconds > 0 || days == 0 && hours == 0 && minutes == 0) '$seconds秒', + ]; + return '${negative ? '-' : ''}${parts.join()}'; + } + + String _speed(num? value) { + if (value == null || !value.isFinite || value == 0) return '暂无数据'; + final text = value + .toStringAsFixed(2) + .replaceFirst(RegExp(r'0+$'), '') + .replaceFirst( + RegExp(r'\.$'), + '', + ); + return '$text 倍'; + } + + Widget _section(String title) => Padding( + padding: const .fromLTRB(16, 20, 16, 4), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ); + + Widget _item(String title, String value, [String? subtitle]) => ListTile( + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle), + trailing: Text(value, style: Theme.of(context).textTheme.titleMedium), + ); + + Future _reset() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('清空倍速统计?'), + content: const Text('这会删除已经累计的播放、倒带与直播统计,无法撤销。'), + actions: [ + TextButton(onPressed: Get.back, child: const Text('取消')), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text('清空'), + ), + ], + ), + ); + if (confirmed == true) { + await PlaybackStatsService.reset(); + if (mounted) setState(() => stats = PlaybackStatsService.snapshot()); + } + } + + @override + Widget build(BuildContext context) { + final favorite = _derived['favoriteSpeed'] as num?; + final favoriteDefault = _derived['favoriteSpeedWasDefault'] == true; + final eligibleRewinds = _value('eligibleRewindCount'); + final completionRate = _derived['rewindCompletionRate'] as num? ?? 0; + final speedSelections = + (_derived['speedSelectionCounts'] as Map?)?.entries.toList() + ?..sort((a, b) => (b.value as num).compareTo(a.value as num)); + + return SimpleScaffold( + appBar: AppBar( + title: const Text('倍速统计详情'), + actions: [ + TextButton(onPressed: _reset, child: const Text('清空')), + const SizedBox(width: 8), + ], + ), + body: ViewSafeArea( + child: ListView( + padding: .only(bottom: MediaQuery.viewPaddingOf(context).bottom + 48), + children: [ + Padding( + padding: const .fromLTRB(16, 12, 16, 4), + child: Text( + '这里的“节约时间”只比较连续播放推进的原视频时间与实际播放时间。快进跳过的内容不算节约,暂停和缓冲也单独列出。这样统计的是倍速本身带来的时间变化,而不是把没有观看的内容算成成果。', + style: TextStyle(color: ColorScheme.of(context).outline), + ), + ), + _section('倍速与时间'), + _item( + '您最爱的倍速', + favorite == null + ? '暂无数据' + : '${_speed(favorite)}${favoriteDefault ? '(默认)' : ''}', + '每次手动选择计一次;进入新的 CID 时,当前倍速也计一次', + ), + _item('实际平均倍速', _speed(_derived['actualAverageSpeed'] as num?)), + _item( + '名义平均倍速', + _speed(_derived['nominalAverageSpeed'] as num?), + '按播放器设置的倍速与播放时长加权', + ), + _item('实际播放视频时长', _duration(_value('activePlaybackUs'))), + _item('倍速为您节约', _duration(_derived['savedTimeUs'] as num? ?? 0)), + _item( + '播放器累计消耗时间', + _duration(_derived['playerObservedUs'] as num? ?? 0), + '实际播放、暂停与缓冲等待之和', + ), + _item( + '在该应用上累计消耗的时间', + _duration(_value('appForegroundUs')), + '只统计应用处于前台的时间', + ), + _item('暂停累计时间', _duration(_value('pausedUs'))), + _item('缓冲等待时间', _duration(_value('bufferingUs'))), + _section('倒带回看'), + _item('累计倒带时长', _duration(_value('rewindUs'))), + _item( + '快倍速倒带时长', + _duration(_value('fastRewindUs')), + '倒带发生时倍速大于 1 倍', + ), + _item( + '倒带完播率', + eligibleRewinds == 0 + ? '暂无数据' + : '${(completionRate * 100).toStringAsFixed(1)}%', + '重新播放到倒带前检查点视为完成;未完成且停留不足 5 秒不进入分母', + ), + _item( + '倒带后平均等效倍速', + _speed(_derived['rewindEquivalentSpeed'] as num?), + '已完成倒带的原视频时长 ÷ 实际重新播放时长', + ), + _section('直播'), + _item('直播累计打开次数', '${_value('liveOpenCount').toInt()} 次'), + _item('直播累计观看时间', _duration(_value('liveWatchUs'))), + ExpansionTile( + title: const Text('高级参数回看'), + subtitle: const Text('查看可供重新计算或交给 AI 分析的原始统计量'), + childrenPadding: const .fromLTRB(16, 0, 16, 20), + children: [ + if (speedSelections?.isNotEmpty == true) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const .only(bottom: 12), + child: Text( + '倍速选择次数:${speedSelections!.map((entry) => '${entry.key}× ${entry.value}次').join(',')}', + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: Text( + '数据按正交原语保存:正常观看与倒带重看各自的播放、暂停和缓冲墙钟,以及媒体推进、倍速分桶、名义倍速积分、前进跳转、倒带检查点和按主播 UID 汇总的直播时间。展示公式以后即使调整,也可以用这些原语重新计算。', + style: TextStyle(color: ColorScheme.of(context).outline), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + onPressed: () { + Clipboard.setData( + ClipboardData( + text: PlaybackStatsService.advancedJson(), + ), + ); + SmartDialog.showToast('高级参数已复制'); + }, + icon: const Icon(Icons.copy_outlined), + label: const Text('复制高级参数'), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: SelectableText( + PlaybackStatsService.advancedJson(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/setting/widgets/select_dialog.dart b/lib/pages/setting/widgets/select_dialog.dart index 1cc7c7a9ef..97f831f2c4 100644 --- a/lib/pages/setting/widgets/select_dialog.dart +++ b/lib/pages/setting/widgets/select_dialog.dart @@ -6,6 +6,7 @@ import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/common/video/cdn_type.dart'; import 'package:PiliPlus/models/common/video/video_type.dart'; import 'package:PiliPlus/models/video/play/url.dart'; +import 'package:PiliPlus/utils/connectivity_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/video_utils.dart'; import 'package:dio/dio.dart'; @@ -107,7 +108,7 @@ class _CdnSelectDialogState extends State { length, (_) => ValueNotifier(null), ); - _tokens = List.generate(length, (_) => CancelToken()); + _tokens = List.filled(length, null); _startSpeedTest(); } super.initState(); @@ -141,27 +142,39 @@ class _CdnSelectDialogState extends State { Future _startSpeedTest() async { try { + final limits = + (await ConnectivityUtils.resolveForPlayback()) + .useCellularPreferences + ? (warmup: 4194304, max: 16777216) + : (warmup: 8388608, max: 67108864); final videoItem = widget.sample ?? await _getSampleUrl(); - await _testAllCdnServices(videoItem); + await _testAllCdnServices(videoItem, limits); } catch (e) { if (kDebugMode) debugPrint('CDN speed test failed: $e'); } } - Future _testAllCdnServices(BaseItem videoItem) async { + Future _testAllCdnServices( + BaseItem videoItem, + ({int warmup, int max}) limits, + ) async { for (final item in CDNService.values) { if (!mounted) break; - await _testSingleCdn(item, videoItem); + await _testSingleCdn(item, videoItem, limits); } } - Future _testSingleCdn(CDNService item, BaseItem videoItem) async { + Future _testSingleCdn( + CDNService item, + BaseItem videoItem, + ({int warmup, int max}) limits, + ) async { try { final cdnUrl = VideoUtils.getCdnUrl( videoItem.playUrls, defaultCDNService: item, ); - await _measureDownloadSpeed(cdnUrl, item.index); + await _measureDownloadSpeed(cdnUrl, item.index, limits); } catch (e) { _handleSpeedTestError(e, item.index); } @@ -169,50 +182,204 @@ class _CdnSelectDialogState extends State { late final Dio _dio; - Future _measureDownloadSpeed(String url, int index) async { - const maxSize = 8 * 1024 * 1024; - int downloaded = 0; + Future _measureDownloadSpeed( + String url, + int index, + ({int warmup, int max}) limits, + ) async { + _CdnSpeedSample sample; + try { + sample = await _measureStream(url, index, limits); + } catch (e) { + if (!mounted) return; + if (kDebugMode) debugPrint('CDN stream speed test failed: $e'); + sample = await _measureLegacy(url, index, limits); + } + if (mounted) _updateSpeedResult(index, sample); + } - final cancelToken = _tokens[index]; - final start = DateTime.now().microsecondsSinceEpoch; + CancelToken _newToken(int index) { + final token = CancelToken(); + _tokens[index]?.cancel(); + _tokens[index] = token; + return token; + } - void onClose() { - cancelToken?.cancel(); - _tokens[index] = null; - } + Future<_CdnSpeedSample> _measureStream( + String url, + int index, + ({int warmup, int max}) limits, + ) async { + final token = _newToken(index); + final watch = Stopwatch()..start(); + Timer? measureTimer; + var intentionalStop = false; + var downloaded = 0; + int? firstByteUs; + int? sampleStartUs; + var sampleStartBytes = 0; + + final totalTimer = Timer(const Duration(seconds: 15), () { + intentionalStop = true; + token.cancel(); + }); + + try { + final response = await _dio.get( + url, + cancelToken: token, + options: Options( + headers: {'range': 'bytes=0-${limits.max - 1}'}, + responseType: ResponseType.stream, + receiveTimeout: Duration.zero, + validateStatus: (status) => status == 200 || status == 206, + ), + ); + final stream = response.data?.stream; + if (stream == null) throw StateError('测速响应为空'); + + await for (final chunk in stream) { + if (chunk.isEmpty) continue; + final now = watch.elapsedMicroseconds; + firstByteUs ??= now; + final total = downloaded + chunk.length; + downloaded = total > limits.max ? limits.max : total; - await _dio.get( - url, - cancelToken: cancelToken, - onReceiveProgress: (count, total) { - if (!mounted) { - return; + if (sampleStartUs == null && downloaded >= limits.warmup) { + sampleStartUs = now; + sampleStartBytes = downloaded; + measureTimer = Timer(const Duration(seconds: 8), () { + intentionalStop = true; + token.cancel(); + }); } + if (downloaded >= limits.max) break; + } + } on DioException { + if (!intentionalStop) rethrow; + } finally { + totalTimer.cancel(); + measureTimer?.cancel(); + if (identical(_tokens[index], token)) _tokens[index] = null; + } + + return _buildSample( + watch: watch, + downloaded: downloaded, + firstByteUs: firstByteUs, + sampleStartUs: sampleStartUs, + sampleStartBytes: sampleStartBytes, + type: downloaded >= limits.max + ? _CdnSpeedSampleType.complete + : _CdnSpeedSampleType.partial, + ); + } + + Future<_CdnSpeedSample> _measureLegacy( + String url, + int index, + ({int warmup, int max}) limits, + ) async { + final token = _newToken(index); + final watch = Stopwatch()..start(); + Timer? measureTimer; + var intentionalStop = false; + var downloaded = 0; + int? firstByteUs; + int? sampleStartUs; + var sampleStartBytes = 0; - final duration = DateTime.now().microsecondsSinceEpoch - start; + final totalTimer = Timer(const Duration(seconds: 15), () { + intentionalStop = true; + token.cancel(); + }); - downloaded += count; + try { + await _dio.get( + url, + cancelToken: token, + onReceiveProgress: (count, _) { + if (count <= 0 || intentionalStop) return; + final now = watch.elapsedMicroseconds; + firstByteUs ??= now; + downloaded = count > limits.max ? limits.max : count; - if (duration > 15000000) { - onClose(); - if (downloaded > 0) { - _updateSpeedResult(index, downloaded, duration); - downloaded = 0; - } else { - throw TimeoutException('测速超时'); + if (sampleStartUs == null && downloaded >= limits.warmup) { + sampleStartUs = now; + sampleStartBytes = downloaded; + measureTimer = Timer(const Duration(seconds: 8), () { + intentionalStop = true; + token.cancel(); + }); } - } else if (downloaded >= maxSize) { - onClose(); - _updateSpeedResult(index, downloaded, duration); - downloaded = 0; - } - }, + if (downloaded >= limits.max) { + intentionalStop = true; + token.cancel(); + } + }, + ); + } on DioException { + if (!intentionalStop) rethrow; + } finally { + totalTimer.cancel(); + measureTimer?.cancel(); + if (identical(_tokens[index], token)) _tokens[index] = null; + } + + return _buildSample( + watch: watch, + downloaded: downloaded, + firstByteUs: firstByteUs, + sampleStartUs: sampleStartUs, + sampleStartBytes: sampleStartBytes, + type: _CdnSpeedSampleType.fallback, ); } - void _updateSpeedResult(int index, int downloaded, int duration) { - final speed = (downloaded / duration).toStringAsPrecision(3); - _cdnResList[index].value = '${speed}MB/s'; + _CdnSpeedSample _buildSample({ + required Stopwatch watch, + required int downloaded, + required int? firstByteUs, + required int? sampleStartUs, + required int sampleStartBytes, + required _CdnSpeedSampleType type, + }) { + watch.stop(); + if (downloaded <= 0 || firstByteUs == null) { + throw TimeoutException('测速超时'); + } + + var bytes = downloaded - sampleStartBytes; + var startUs = sampleStartUs; + if (bytes <= 0 || startUs == null) { + bytes = downloaded; + startUs = firstByteUs; + } + final elapsedUs = watch.elapsedMicroseconds - startUs; + return ( + bytes: bytes, + elapsedUs: elapsedUs > 0 ? elapsedUs : 1, + firstByteUs: firstByteUs, + type: type, + ); + } + + void _updateSpeedResult(int index, _CdnSpeedSample sample) { + final isFallback = sample.type == _CdnSpeedSampleType.fallback; + final divisor = isFallback ? 1000 * 1000 : 1024 * 1024; + final speed = + sample.bytes * + Duration.microsecondsPerSecond / + sample.elapsedUs / + divisor; + final unit = switch (sample.type) { + _CdnSpeedSampleType.complete => 'MiB/s', + _CdnSpeedSampleType.partial => 'M/s', + _CdnSpeedSampleType.fallback => 'MB/s', + }; + final firstByteMs = sample.firstByteUs / 1000; + _cdnResList[index].value = + '${speed.toStringAsPrecision(3)} $unit · 首包 ${firstByteMs.toStringAsPrecision(3)}ms'; } void _handleSpeedTestError(dynamic error, int index) { @@ -266,3 +433,12 @@ class _CdnSelectDialogState extends State { ); } } + +enum _CdnSpeedSampleType { complete, partial, fallback } + +typedef _CdnSpeedSample = ({ + int bytes, + int elapsedUs, + int firstByteUs, + _CdnSpeedSampleType type, +}); diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index 87d4b6a289..b2d30feeec 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -17,6 +17,7 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/user.dart'; import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/common/account_type.dart'; +import 'package:PiliPlus/models/common/network_profile.dart'; import 'package:PiliPlus/models/common/sponsor_block/action_type.dart'; import 'package:PiliPlus/models/common/sponsor_block/post_segment_model.dart'; import 'package:PiliPlus/models/common/sponsor_block/segment_model.dart'; @@ -53,8 +54,8 @@ import 'package:PiliPlus/plugin/pl_player/models/data_source.dart'; import 'package:PiliPlus/plugin/pl_player/models/heart_beat_type.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_status.dart'; import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/services/playback_stats_service.dart'; import 'package:PiliPlus/utils/accounts.dart'; -import 'package:PiliPlus/utils/connectivity_utils.dart'; import 'package:PiliPlus/utils/extension/context_ext.dart'; import 'package:PiliPlus/utils/extension/file_ext.dart'; import 'package:PiliPlus/utils/extension/iterable_ext.dart'; @@ -65,6 +66,7 @@ import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; +import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/theme_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; @@ -152,7 +154,9 @@ class VideoDetailController extends GetxController Box setting = GStorage.setting; // 预设的解码格式 - late List preferCodecs = Pref.preferCodecs; + late List preferCodecs = + plPlayerController.cachePreferCodecs ?? Pref.preferCodecs; + bool _pendingNetworkRefresh = false; bool get showReply => isFileSource ? false @@ -692,10 +696,11 @@ class VideoDetailController extends GetxController } /// 更新画质、音质 - void updatePlayer() { + void updatePlayer({bool? autoplay}) { final currentVideoQa = this.currentVideoQa.value; if (currentVideoQa == null) return; - _autoPlay.value = true; + autoplay ??= plPlayerController.playerStatus.isPlaying; + _autoPlay.value = autoplay; playedTime = plPlayerController.videoPlayerController?.state.position; plPlayerController ..isBuffering.value = false @@ -713,7 +718,31 @@ class VideoDetailController extends GetxController audioUrl = VideoUtils.getCdnUrl(firstAudio.playUrls, isAudio: true); } - playerInit(); + playerInit(autoplay: autoplay); + } + + Future setDecodeFormat(VideoDecodeFormatType format) async { + final base = plPlayerController.cachePreferCodecs ?? preferCodecs; + final updated = [format, ...base.where((item) => item != format)]; + plPlayerController.cachePreferCodecs = updated; + final peak = plPlayerController.peakPreferCodecs; + preferCodecs = peak == null + ? updated + : [format, ...peak.where((item) => item != format)]; + plPlayerController.peakPreferCodecs = peak == null ? null : preferCodecs; + currentDecodeFormats = format; + updatePlayer(); + if (!plPlayerController.tempPlayerConf) { + final cellular = + plPlayerController.playbackNetworkProfile?.useCellularPreferences ?? + false; + await setting.put( + cellular + ? SettingBoxKey.preferCodecsCellular + : SettingBoxKey.preferCodecs, + updated.map((item) => item.name).toList(), + ); + } } Future? _initPlayerIfNeeded(bool autoFullScreenFlag) { @@ -733,6 +762,7 @@ class VideoDetailController extends GetxController bool? autoplay, bool autoFullScreenFlag = false, }) async { + plPlayerController.onNetworkPolicyChanged = _onNetworkPolicyChanged; Duration? seek = defaultST ?? playedTime; if (seek == .zero) seek = null; seek ??= getFirstSegment(); @@ -806,6 +836,109 @@ class VideoDetailController extends GetxController Volume? volume; + Future _syncNetworkProfile() async { + final profile = await plPlayerController.resolveNetworkProfile(); + final previous = plPlayerController.playbackNetworkProfile; + plPlayerController.applyNetworkProfile( + profile, + resetSessionPreferences: + plPlayerController.cacheVideoQa == null || + previous?.identity != profile.identity, + ); + preferCodecs = plPlayerController.effectivePreferCodecs; + } + + Future _onNetworkPolicyChanged(NetworkPolicyChange change) async { + if (isFileSource || + isClosed || + plPlayerController.isResolvingNetworkProfile) { + return; + } + plPlayerController.applyNetworkProfile( + change.profile, + resetSessionPreferences: change.reason == NetworkPolicyReason.network, + refreshPeakPreferences: change.reason == NetworkPolicyReason.peak, + ); + preferCodecs = plPlayerController.effectivePreferCodecs; + if (change.reason == NetworkPolicyReason.peak) return; + if (isQuerying || currentVideoQa.value == null || data.dash == null) { + _pendingNetworkRefresh = true; + return; + } + await _reloadForNetworkPolicy(); + } + + Future _reloadForNetworkPolicy() async { + _pendingNetworkRefresh = false; + final wasPlaying = plPlayerController.playerStatus.isPlaying; + playedTime = plPlayerController.videoPlayerController?.state.position; + plPlayerController + ..isBuffering.value = false + ..buffered.value = 0; + _selectPreferredStreams(); + await playerInit(autoplay: wasPlaying); + } + + void _selectPreferredStreams() { + final videoList = data.dash!.video!; + final curHighestVideoQa = videoList.first.quality.code; + var targetVideoQa = curHighestVideoQa; + final cacheVideoQa = plPlayerController.cacheVideoQa!; + if (data.acceptQuality?.isNotEmpty == true && + cacheVideoQa <= curHighestVideoQa) { + targetVideoQa = data.acceptQuality!.findClosestTarget( + (quality) => quality <= cacheVideoQa, + (a, b) => a > b ? a : b, + ); + } + currentVideoQa.value = VideoQuality.fromCode(targetVideoQa); + + final supportFormats = data.supportFormats!; + currentDecodeFormats = VideoUtils.selectCodec( + supportFormats + .firstWhere( + (format) => format.quality == targetVideoQa, + orElse: () => supportFormats.first, + ) + .codecs!, + preferCodecs, + ); + final videos = videoList + .where((video) => video.quality.code == targetVideoQa) + .toList(); + firstVideo = videos.firstWhere( + (video) => currentDecodeFormats.codes.any(video.codecs!.startsWith), + orElse: () => videos.first, + ); + _setVideoHeight(); + videoUrl = VideoUtils.getCdnUrl(firstVideo.playUrls); + + final audioList = data.dash?.audio; + if (audioList != null && audioList.isNotEmpty) { + final audioIds = audioList.map((audio) => audio.id!).toList(); + var closestNumber = audioIds.findClosestTarget( + (quality) => quality <= plPlayerController.cacheAudioQa, + (a, b) => a > b ? a : b, + ); + if (!audioIds.contains(plPlayerController.cacheAudioQa) && + audioIds.any( + (quality) => quality > plPlayerController.cacheAudioQa, + )) { + closestNumber = AudioQuality.k192.code; + } + final firstAudio = audioList.firstWhere( + (audio) => audio.id == closestNumber, + orElse: () => audioList.first, + ); + audioUrl = VideoUtils.getCdnUrl(firstAudio.playUrls, isAudio: true); + if (firstAudio.id case final int id?) { + currentAudioQa = AudioQuality.fromCode(id); + } + } else { + audioUrl = ''; + } + } + // 视频链接 /// TODO: merge [DownloadHttp.getVideoUrl]. Future queryVideoUrl({ @@ -819,183 +952,114 @@ class VideoDetailController extends GetxController return; } isQuerying = true; - if (plPlayerController.enableSponsorBlock && isBlock && !fromReset) { - querySponsorBlock(bvid: bvid, cid: cid.value); - } - if (plPlayerController.cacheVideoQa == null) { - final isWiFi = await ConnectivityUtils.isWiFi; - plPlayerController - ..cacheVideoQa = isWiFi - ? Pref.defaultVideoQa - : Pref.defaultVideoQaCellular - ..cacheAudioQa = isWiFi - ? Pref.defaultAudioQa - : Pref.defaultAudioQaCellular; - preferCodecs = isWiFi ? Pref.preferCodecs : Pref.preferCodecsCellular; - } - - final result = await VideoHttp.videoUrl( - cid: cid.value, - bvid: bvid, - epid: epId, - seasonId: seasonId, - tryLook: plPlayerController.tryLook, - videoType: _actualVideoType ?? videoType, - language: currLang.value, - voiceBalance: plPlayerController.enableAudioNormalization, - ); - - if (result case Success(:final response)) { - data = response; + var hasDashResponse = false; + plPlayerController.onNetworkPolicyChanged = _onNetworkPolicyChanged; + try { + if (plPlayerController.enableSponsorBlock && isBlock && !fromReset) { + querySponsorBlock(bvid: bvid, cid: cid.value); + } + await _syncNetworkProfile(); - languages.value = data.language?.items; - currLang.value = data.curLanguage; + final result = await VideoHttp.videoUrl( + cid: cid.value, + bvid: bvid, + epid: epId, + seasonId: seasonId, + tryLook: plPlayerController.tryLook, + videoType: _actualVideoType ?? videoType, + language: currLang.value, + voiceBalance: plPlayerController.enableAudioNormalization, + ); - volume = data.volume; + if (result case Success(:final response)) { + data = response; - if (!fromReset) { - final progress = args.remove('progress'); - if (progress != null) { - defaultST = Duration(milliseconds: progress); - } else { - defaultST = Duration(milliseconds: data.lastPlayTime); - } - } + languages.value = data.language?.items; + currLang.value = data.curLanguage; - if (!isUgc && !fromReset && plPlayerController.enablePgcSkip) { - if (data.clipInfoList case final clipInfoList?) { - resetBlock(); - handleSBData(clipInfoList); - } - } + volume = data.volume; - if (data.acceptDesc?.contains('试看') == true) { - SmartDialog.showToast( - '该视频为专属视频,仅提供试看', - displayTime: const Duration(seconds: 3), - ); - } - if (data.dash == null) { - if (data.durl case final durl?) { - // it will cause all files to be opened simultaneously - if (durl.length > 1) { - // TODO: refa - final sb = StringBuffer('edl://!no_chapters;'); - for (var i in durl) { - final video = VideoUtils.getCdnUrl(i.playUrls); - sb.write('%${video.length}%$video,length=${i.length! / 1000};'); - } - videoUrl = sb.toString(); + if (!fromReset) { + final progress = args.remove('progress'); + if (progress != null) { + defaultST = Duration(milliseconds: progress); } else { - videoUrl = VideoUtils.getCdnUrl(durl.single.playUrls); + defaultST = Duration(milliseconds: data.lastPlayTime); } + } - audioUrl = ''; - - // 实际为FLV/MP4格式,但已被淘汰,这里仅做兜底处理 - final videoQuality = VideoQuality.fromCode(data.quality!); - firstVideo = VideoItem( - id: data.quality!, - baseUrl: videoUrl, - codecs: 'avc1', - quality: videoQuality, - ); - _setVideoHeight(); - currentDecodeFormats = VideoDecodeFormatType.AVC; - currentVideoQa.value = videoQuality; - await _initPlayerIfNeeded(autoFullScreenFlag); - isQuerying = false; - return; - } else { - SmartDialog.showToast('视频资源不存在'); - _autoPlay.value = false; - videoState.value = false; - if (plPlayerController.isFullScreen.value) { - plPlayerController.triggerFullScreen(status: false); + if (!isUgc && !fromReset && plPlayerController.enablePgcSkip) { + if (data.clipInfoList case final clipInfoList?) { + resetBlock(); + handleSBData(clipInfoList); } - isQuerying = false; - return; } - } - - final List videoList = data.dash!.video!; - // if (kDebugMode) debugPrint("allVideosList:${allVideosList}"); - // 当前可播放的最高质量视频 - final curHighestVideoQa = videoList.first.quality.code; - // 预设的画质为null,则当前可用的最高质量 - int targetVideoQa = curHighestVideoQa; - if (data.acceptQuality?.isNotEmpty == true && - plPlayerController.cacheVideoQa! <= curHighestVideoQa) { - // 如果预设的画质低于当前最高 - targetVideoQa = data.acceptQuality!.findClosestTarget( - (e) => e <= plPlayerController.cacheVideoQa!, - (a, b) => a > b ? a : b, - ); - } - currentVideoQa.value = VideoQuality.fromCode(targetVideoQa); - /// 优先顺序 设置中指定解码格式 -> 当前可选的首个解码格式 - final supportFormats = data.supportFormats!; - - // 根据画质选编码格式 - currentDecodeFormats = VideoUtils.selectCodec( - supportFormats - .firstWhere( - (e) => e.quality == targetVideoQa, - orElse: () => supportFormats.first, - ) - .codecs!, - preferCodecs, - ); + if (data.acceptDesc?.contains('试看') == true) { + SmartDialog.showToast( + '该视频为专属视频,仅提供试看', + displayTime: const Duration(seconds: 3), + ); + } + if (data.dash == null) { + if (data.durl case final durl?) { + // it will cause all files to be opened simultaneously + if (durl.length > 1) { + // TODO: refa + final sb = StringBuffer('edl://!no_chapters;'); + for (var i in durl) { + final video = VideoUtils.getCdnUrl(i.playUrls); + sb.write('%${video.length}%$video,length=${i.length! / 1000};'); + } + videoUrl = sb.toString(); + } else { + videoUrl = VideoUtils.getCdnUrl(durl.single.playUrls); + } - /// 取出符合当前画质的videoList - final videosList = videoList - .where((e) => e.quality.code == targetVideoQa) - .toList(); + audioUrl = ''; - /// 取出符合当前解码格式的videoItem - firstVideo = videosList.firstWhere( - (e) => currentDecodeFormats.codes.any(e.codecs!.startsWith), - orElse: () => videosList.first, - ); - _setVideoHeight(); - - videoUrl = VideoUtils.getCdnUrl(firstVideo.playUrls); - - /// 优先顺序 设置中指定质量 -> 当前可选的最高质量 - AudioItem? firstAudio; - final audioList = data.dash?.audio; - if (audioList != null && audioList.isNotEmpty) { - final List audioIds = audioList.map((map) => map.id!).toList(); - int closestNumber = audioIds.findClosestTarget( - (e) => e <= plPlayerController.cacheAudioQa, - (a, b) => a > b ? a : b, - ); - if (!audioIds.contains(plPlayerController.cacheAudioQa) && - audioIds.any((e) => e > plPlayerController.cacheAudioQa)) { - closestNumber = AudioQuality.k192.code; - } - firstAudio = audioList.firstWhere( - (e) => e.id == closestNumber, - orElse: () => audioList.first, - ); - audioUrl = VideoUtils.getCdnUrl(firstAudio.playUrls, isAudio: true); - if (firstAudio.id case final int id?) { - currentAudioQa = AudioQuality.fromCode(id); + // 实际为FLV/MP4格式,但已被淘汰,这里仅做兜底处理 + final videoQuality = VideoQuality.fromCode(data.quality!); + firstVideo = VideoItem( + id: data.quality!, + baseUrl: videoUrl, + codecs: 'avc1', + quality: videoQuality, + ); + _setVideoHeight(); + currentDecodeFormats = VideoDecodeFormatType.AVC; + currentVideoQa.value = videoQuality; + await _initPlayerIfNeeded(autoFullScreenFlag); + return; + } else { + SmartDialog.showToast('视频资源不存在'); + _autoPlay.value = false; + videoState.value = false; + if (plPlayerController.isFullScreen.value) { + plPlayerController.triggerFullScreen(status: false); + } + return; + } } + hasDashResponse = true; + _pendingNetworkRefresh = false; + preferCodecs = plPlayerController.effectivePreferCodecs; + _selectPreferredStreams(); + await _initPlayerIfNeeded(autoFullScreenFlag); } else { - audioUrl = ''; + _autoPlay.value = false; + videoState.value = false; + if (plPlayerController.isFullScreen.value) { + plPlayerController.triggerFullScreen(status: false); + } + result.toast(); } - await _initPlayerIfNeeded(autoFullScreenFlag); - } else { - _autoPlay.value = false; - videoState.value = false; - if (plPlayerController.isFullScreen.value) { - plPlayerController.triggerFullScreen(status: false); + } finally { + isQuerying = false; + if (_pendingNetworkRefresh && hasDashResponse) { + await _reloadForNetworkPolicy(); } - result.toast(); } - isQuerying = false; } late final List postList = []; @@ -1262,6 +1326,13 @@ class VideoDetailController extends GetxController @override void onClose() { + PlaybackStatsService.endMediaIfMatches( + isLive: false, + cid: cid.value, + position: + plPlayerController.videoPlayerController?.state.position ?? + Duration.zero, + ); cid.close(); if (isFileSource) { cacheLocalProgress(); diff --git a/lib/pages/video/member/controller.dart b/lib/pages/video/member/controller.dart index 086eeb5cf9..6494be368f 100644 --- a/lib/pages/video/member/controller.dart +++ b/lib/pages/video/member/controller.dart @@ -11,7 +11,7 @@ import 'package:get/get.dart'; class HorizontalMemberPageController extends CommonListController { - HorizontalMemberPageController({this.mid, required this.currAid}); + HorizontalMemberPageController({this.mid}); dynamic mid; @@ -56,37 +56,20 @@ class HorizontalMemberPageController bool customHandleResponse(bool isRefresh, Success response) { SpaceArchiveData data = response.response; count = data.count; - if (isRefresh) { - if (isLoadPrevious) { - hasPrev = data.hasPrev ?? false; - } else { - hasNext = data.hasNext ?? false; - } - } - if (isLoadPrevious) { - if (loadingState.value case Success(:final response)) { - (data.item ??= []).addAll(response!); - } - } else if (!isRefresh) { + hasNext = data.hasNext ?? false; + if (!isRefresh) { if (loadingState.value case Success(:final response)) { (data.item ??= []).insertAll(0, response!); } } - firstAid = data.item?.firstOrNull?.param; lastAid = data.item?.lastOrNull?.param; loadingState.value = Success(data.item); - isLoadPrevious = false; - page++; return true; } - String? currAid; - String? firstAid; String? lastAid; ArchiveOrderTypeApp order = .pubdate; int? count; - bool isLoadPrevious = false; - bool hasPrev = true; bool hasNext = true; @override @@ -94,37 +77,25 @@ class HorizontalMemberPageController MemberHttp.spaceArchive( type: .video, mid: mid, - aid: page == 1 - ? currAid - : isLoadPrevious - ? firstAid - : lastAid, + aid: page == 1 ? null : lastAid, order: order, - sort: page != 1 && isLoadPrevious ? .asc : null, + sort: null, pn: null, next: null, seasonId: null, seriesId: null, - includeCursor: page == 1 ? true : null, + includeCursor: null, ); @override Future onRefresh() { - if (!hasPrev) { - return Future.value(null); - } - isLoadPrevious = true; - return queryData(); + lastAid = null; + hasNext = true; + return super.onRefresh(); } @override Future onReload() { - firstAid = null; - lastAid = null; - hasNext = true; - hasPrev = true; - isEnd = false; - page = 1; scrollController.jumpToTop(); return super.onReload(); } diff --git a/lib/pages/video/member/view.dart b/lib/pages/video/member/view.dart index c83a1fbe88..a6efd73276 100644 --- a/lib/pages/video/member/view.dart +++ b/lib/pages/video/member/view.dart @@ -56,20 +56,10 @@ class _HorizontalMemberPageState extends State { _controller = Get.put( HorizontalMemberPageController( mid: widget.mid, - currAid: widget.videoDetailController.aid.toString(), ), tag: widget.videoDetailController.heroTag, ); _bvid = widget.videoDetailController.bvid; - if (_controller.loadingState.value - case Success?> res) { - final index = res.response?.indexWhere((e) => e.bvid == _bvid) ?? -1; - if (index != -1) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _controller.scrollController.jumpTo(112.0 * index); - }); - } - } } @override @@ -153,9 +143,7 @@ class _HorizontalMemberPageState extends State { Widget _buildSortBtn(ThemeData theme) { return TextButton.icon( style: Style.buttonStyle, - onPressed: () => _controller - ..lastAid = widget.videoDetailController.aid.toString() - ..queryBySort(), + onPressed: _controller.queryBySort, icon: Icon( Icons.sort, size: 16, diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index f02e04df2a..7ffcf94e75 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -43,7 +43,6 @@ import 'package:PiliPlus/services/shutdown_timer_service.dart' show shutdownTimerService; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/accounts/account.dart'; -import 'package:PiliPlus/utils/connectivity_utils.dart'; import 'package:PiliPlus/utils/extension/num_ext.dart'; import 'package:PiliPlus/utils/extension/string_ext.dart'; import 'package:PiliPlus/utils/image_utils.dart'; @@ -945,7 +944,7 @@ class HeaderControlState extends State final isCurr = currentVideoQa.code == item.quality; return ListTile( dense: true, - onTap: () async { + onTap: () { if (isCurr) { return; } @@ -962,9 +961,11 @@ class HeaderControlState extends State // update if (!plPlayerController.tempPlayerConf) { setting.put( - await ConnectivityUtils.isWiFi - ? SettingBoxKey.defaultVideoQa - : SettingBoxKey.defaultVideoQaCellular, + plPlayerController.playbackNetworkProfile + ?.useCellularPreferences == + true + ? SettingBoxKey.defaultVideoQaCellular + : SettingBoxKey.defaultVideoQa, quality, ); } @@ -1025,7 +1026,7 @@ class HeaderControlState extends State final isCurr = currentAudioQa.code == item.id; return ListTile( dense: true, - onTap: () async { + onTap: () { if (isCurr) { return; } @@ -1042,9 +1043,11 @@ class HeaderControlState extends State // update if (!plPlayerController.tempPlayerConf) { setting.put( - await ConnectivityUtils.isWiFi - ? SettingBoxKey.defaultAudioQa - : SettingBoxKey.defaultAudioQaCellular, + plPlayerController.playbackNetworkProfile + ?.useCellularPreferences == + true + ? SettingBoxKey.defaultAudioQaCellular + : SettingBoxKey.defaultAudioQa, quality, ); } @@ -1116,12 +1119,10 @@ class HeaderControlState extends State final isCurr = curCodecs.any(item.startsWith); return ListTile( dense: true, - onTap: () { + onTap: () async { if (isCurr) return; Get.back(); - videoDetailCtr - ..currentDecodeFormats = format - ..updatePlayer(); + await videoDetailCtr.setDecodeFormat(format); SmartDialog.showToast("解码已变为:${format.name}"); }, contentPadding: const .symmetric(horizontal: 20), diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 1459237324..57b6317598 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -13,7 +13,9 @@ import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/media_kit_adapt/media_kit_adapt.dart'; import 'package:PiliPlus/models/common/account_type.dart'; import 'package:PiliPlus/models/common/audio_normalization.dart'; +import 'package:PiliPlus/models/common/network_profile.dart'; import 'package:PiliPlus/models/common/super_resolution_type.dart'; +import 'package:PiliPlus/models/common/video/video_decode_type.dart'; import 'package:PiliPlus/models/common/video/video_type.dart'; import 'package:PiliPlus/models/user/danmaku_rule.dart'; import 'package:PiliPlus/models/video/play/url.dart'; @@ -33,9 +35,11 @@ import 'package:PiliPlus/plugin/pl_player/models/play_status.dart'; import 'package:PiliPlus/plugin/pl_player/models/video_fit_type.dart'; import 'package:PiliPlus/plugin/pl_player/utils/fullscreen.dart'; import 'package:PiliPlus/services/service_locator.dart'; +import 'package:PiliPlus/services/playback_stats_service.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/android/bindings.g.dart'; import 'package:PiliPlus/utils/asset_utils.dart'; +import 'package:PiliPlus/utils/connectivity_utils.dart'; import 'package:PiliPlus/utils/device_utils.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/extension/box_ext.dart'; @@ -155,7 +159,10 @@ class PlPlayerController with BlockConfigMixin { late DataSource dataSource; Timer? _timer; + Timer? _weakWifiBufferTimer; StreamSubscription? _subForSeek; + StreamSubscription? _networkPolicySubscription; + Future Function(NetworkPolicyChange change)? onNetworkPolicyChanged; // 鸿蒙:mpv 的 ohaudio 音频输出感知不到系统音频打断(如其他 app 抢占焦点), // 被打断后 mpv 仍自认为在播放,进度停滞、UI 按钮状态错误且无法点击恢复。 @@ -370,6 +377,8 @@ class PlPlayerController with BlockConfigMixin { late List speedList = Pref.speedList; late bool enableAutoLongPressSpeed = Pref.enableAutoLongPressSpeed; + late double longPressSpeedFactor = Pref.longPressSpeedFactor; + late bool enableLongPressSlideSpeed = Pref.enableLongPressSlideSpeed; late final showControlDuration = Pref.enableLongShowControl ? const Duration(seconds: 30) : const Duration(seconds: 3); @@ -416,8 +425,12 @@ class PlPlayerController with BlockConfigMixin { late final bool tempPlayerConf = Pref.tempPlayerConf; - late int? cacheVideoQa = PlatformUtils.isMobile ? null : Pref.defaultVideoQa; + int? cacheVideoQa; late int cacheAudioQa = Pref.defaultAudioQa; + List? cachePreferCodecs; + List? peakPreferCodecs; + NetworkProfile? playbackNetworkProfile; + int _networkProfileResolutions = 0; bool enableHeart = true; late final String? hwdec = Pref.enableHA ? Pref.hardwareDecoding : null; @@ -517,8 +530,13 @@ class PlPlayerController with BlockConfigMixin { static Future seekToIfExists( Duration position, { bool isSeek = true, + bool recordStats = true, }) async { - await _instance?.seekTo(position, isSeek: isSeek); + await _instance?.seekTo( + position, + isSeek: isSeek, + recordStats: recordStats, + ); } static double? getVolumeIfExists() { @@ -640,6 +658,9 @@ class PlPlayerController with BlockConfigMixin { // 添加一个私有构造函数 PlPlayerController._() { + _networkPolicySubscription = ConnectivityUtils.changes.listen((change) { + onNetworkPolicyChanged?.call(change); + }); if (PlatformUtils.isMobile) { _orientationListener = NativeDeviceOrientationCommunicator() .onOrientationChanged( @@ -676,6 +697,60 @@ class PlPlayerController with BlockConfigMixin { } } + void applyNetworkProfile( + NetworkProfile profile, { + required bool resetSessionPreferences, + bool refreshPeakPreferences = false, + }) { + playbackNetworkProfile = profile; + if (resetSessionPreferences) { + cacheVideoQa = profile.useCellularPreferences + ? Pref.defaultVideoQaCellular + : Pref.defaultVideoQa; + cacheAudioQa = profile.useCellularPreferences + ? Pref.defaultAudioQaCellular + : Pref.defaultAudioQa; + cachePreferCodecs = profile.useCellularPreferences + ? Pref.preferCodecsCellular + : Pref.preferCodecs; + } + final peakActive = ConnectivityUtils.isNetworkPeak(profile); + if (resetSessionPreferences || + refreshPeakPreferences || + peakActive != (peakPreferCodecs != null)) { + cachePreferCodecs ??= profile.useCellularPreferences + ? Pref.preferCodecsCellular + : Pref.preferCodecs; + peakPreferCodecs = peakActive + ? ConnectivityUtils.effectiveCodecs(cachePreferCodecs!, profile) + : null; + } + } + + Future resolveNetworkProfile() async { + _networkProfileResolutions++; + try { + return await ConnectivityUtils.resolveForPlayback(); + } finally { + _networkProfileResolutions--; + } + } + + bool get isResolvingNetworkProfile => _networkProfileResolutions != 0; + + List get effectivePreferCodecs => + peakPreferCodecs ?? + cachePreferCodecs ?? + (playbackNetworkProfile?.useCellularPreferences == true + ? Pref.preferCodecsCellular + : Pref.preferCodecs); + + void _onUserLeaveHint() { + if (playerStatus.isPlaying && _isCurrVideoPage) { + enterPip(); + } + } + // 获取实例 传参 static PlPlayerController getInstance({bool isLive = false}) { // 如果实例尚未创建,则创建一个新实例 @@ -724,6 +799,7 @@ class PlPlayerController with BlockConfigMixin { int? epid, int? seasonId, int? pgcType, + int? liveUid, VideoType? videoType, VoidCallback? onInit, Volume? volume, @@ -754,6 +830,7 @@ class PlPlayerController with BlockConfigMixin { epid: epid, seasonId: seasonId, pgcType: pgcType, + liveUid: liveUid, videoType: videoType, onInit: onInit, volume: volume, @@ -787,11 +864,14 @@ class PlPlayerController with BlockConfigMixin { int? epid, int? seasonId, int? pgcType, + int? liveUid, VideoType? videoType, VoidCallback? onInit, Volume? volume, bool autoFullScreenFlag = false, }) async { + final previousPosition = + _videoPlayerController?.state.position ?? Duration.zero; try { _processing = true; this.isLive = isLive; @@ -830,6 +910,15 @@ class PlPlayerController with BlockConfigMixin { if (_playerCount == 0) { return; } + PlaybackStatsService.openMedia( + isLive: isLive, + previousPosition: previousPosition, + initialPosition: seekTo ?? Duration.zero, + speed: _playbackSpeed.value, + defaultSpeed: Pref.playSpeedDefault, + cid: cid, + liveUid: liveUid, + ); // 配置Player 音轨、字幕等等 await _createVideoController(dataSource, seekTo, volume); @@ -974,6 +1063,8 @@ class PlPlayerController with BlockConfigMixin { Duration? seekTo, Volume? volume, ) async { + _weakWifiBufferTimer?.cancel(); + _weakWifiBufferTimer = null; isBuffering.value = false; _heartDuration = 0; danmakuController?.clear(); @@ -1082,6 +1173,9 @@ class PlPlayerController with BlockConfigMixin { SmartDialog.showToast('视频源为空,请重新进入本页面'); return false; } + PlaybackStatsService.rebase( + _videoPlayerController?.state.position ?? Duration.zero, + ); String? audioUri; if (!isLive) { if (dataSource.audioSource.isNullOrEmpty) { @@ -1118,10 +1212,10 @@ class PlPlayerController with BlockConfigMixin { if (_instance == null) return; // 设置倍速 if (isLive) { - await setPlaybackSpeed(1.0); + await setPlaybackSpeed(1.0, recordSelection: false); } else { if (_videoPlayerController?.state.rate != _playbackSpeed.value) { - await setPlaybackSpeed(_playbackSpeed.value); + await setPlaybackSpeed(_playbackSpeed.value, recordSelection: false); } } _initVideoFit(); @@ -1161,6 +1255,7 @@ class PlPlayerController with BlockConfigMixin { _subscriptions = [ /// playing stream.playing.listen((bool playing) { + PlaybackStatsService.updatePlaying(playing, player.state.position); WakelockPlus.toggle(enable: playing); if (playing) { if (_isAutoEnterPip) { @@ -1219,6 +1314,7 @@ class PlPlayerController with BlockConfigMixin { ///completed stream.completed.listen((bool completed) { if (completed) { + PlaybackStatsService.markCompleted(player.state.position); playerStatus.value = .completed; for (final element in _statusListeners) { @@ -1231,6 +1327,7 @@ class PlPlayerController with BlockConfigMixin { /// position stream.position.listen((Duration position) { + PlaybackStatsService.samplePosition(position); final posInSeconds = position.inSeconds; if (posInSeconds != this.position.value) { @@ -1252,7 +1349,22 @@ class PlPlayerController with BlockConfigMixin { buffered.value = buffer.inSeconds; }), stream.buffering.listen((bool buffering) { + PlaybackStatsService.updateBuffering( + buffering, + player.state.position, + ); isBuffering.value = buffering; + if (buffering && !isLive) { + _weakWifiBufferTimer ??= Timer(const Duration(seconds: 5), () { + _weakWifiBufferTimer = null; + if (isBuffering.value && playerStatus.isPlaying) { + ConnectivityUtils.markCurrentWifiWeak(); + } + }); + } else { + _weakWifiBufferTimer?.cancel(); + _weakWifiBufferTimer = null; + } videoPlayerServiceHandler?.onStatusChange( playerStatus.value, buffering, @@ -1390,13 +1502,22 @@ class PlPlayerController with BlockConfigMixin { } /// 跳转至指定位置 - Future seekTo(Duration position, {bool isSeek = true}) async { + Future seekTo( + Duration position, { + bool isSeek = true, + bool recordStats = true, + }) async { if (_playerCount == 0) { return; } if (position < Duration.zero) { position = Duration.zero; } + PlaybackStatsService.seek( + _videoPlayerController?.state.position ?? Duration.zero, + position, + userInitiated: recordStats, + ); _heartDuration = position.inSeconds; Future seek() async { @@ -1425,13 +1546,22 @@ class PlPlayerController with BlockConfigMixin { } /// 设置倍速 - Future setPlaybackSpeed(double speed) async { + Future setPlaybackSpeed( + double speed, { + bool recordSelection = true, + }) async { lastPlaybackSpeed = playbackSpeed; if (speed == _videoPlayerController?.state.rate) { return; } + PlaybackStatsService.changeSpeed( + speed, + _videoPlayerController?.state.position ?? Duration.zero, + recordSelection: recordSelection, + ); + await _videoPlayerController?.setRate(speed); _playbackSpeed.value = speed; if (danmakuController != null) { @@ -1465,7 +1595,7 @@ class PlPlayerController with BlockConfigMixin { // repeat为true,将从头播放 if (repeat) { // await seekTo(Duration.zero); - await seekTo(Duration.zero, isSeek: false); + await seekTo(Duration.zero, isSeek: false, recordStats: false); } // 鸿蒙:被系统打断后 mpv 的音频渲染器已被暂停且 mpv 自身无感知, @@ -1633,6 +1763,7 @@ class PlPlayerController with BlockConfigMixin { } Timer? longPressTimer; + double? _longPressRestoreSpeed; void cancelLongPressTimer() { longPressTimer?.cancel(); longPressTimer = null; @@ -1651,17 +1782,32 @@ class PlPlayerController with BlockConfigMixin { } if (val) { if (playerStatus.isPlaying) { + _longPressRestoreSpeed = playbackSpeed; longPressStatus.value = val; HapticFeedback.lightImpact(); await setPlaybackSpeed( - enableAutoLongPressSpeed ? playbackSpeed * 2 : longPressSpeed, + enableAutoLongPressSpeed + ? playbackSpeed * longPressSpeedFactor + : longPressSpeed, + recordSelection: false, ); } } else { - // if (kDebugMode) debugPrint('$playbackSpeed'); longPressStatus.value = val; - await setPlaybackSpeed(lastPlaybackSpeed); + final restoreSpeed = _longPressRestoreSpeed ?? lastPlaybackSpeed; + _longPressRestoreSpeed = null; + await setPlaybackSpeed(restoreSpeed, recordSelection: false); + } + } + + Future adjustLongPressSpeed(int steps) { + if (!longPressStatus.value || !enableLongPressSlideSpeed || steps == 0) { + return Future.value(); } + return setPlaybackSpeed( + max(0.25, playbackSpeed + steps * 0.25), + recordSelection: false, + ); } bool get isCompleted => @@ -1671,7 +1817,7 @@ class PlPlayerController with BlockConfigMixin { // 双击播放、暂停 Future onDoubleTapCenter() async { if (!isLive && isCompleted) { - await videoPlayerController!.seek(Duration.zero); + await seekTo(Duration.zero, isSeek: false, recordStats: false); videoPlayerController!.play(); } else { videoPlayerController!.playOrPause(); @@ -2025,6 +2171,11 @@ class PlPlayerController with BlockConfigMixin { AndroidHelper$ToDart.onUserLeaveHint = null; } _timer?.cancel(); + _weakWifiBufferTimer?.cancel(); + _weakWifiBufferTimer = null; + _networkPolicySubscription?.cancel(); + _networkPolicySubscription = null; + onNetworkPolicyChanged = null; // _position.close(); // _playerEventSubs?.cancel(); // _sliderPosition.close(); @@ -2051,6 +2202,9 @@ class PlPlayerController with BlockConfigMixin { if (kDebugMode) { debugPrint('dispose player'); } + PlaybackStatsService.endMedia( + _videoPlayerController?.state.position ?? Duration.zero, + ); await _videoPlayerController?.dispose(); _videoPlayerController = null; _videoController = null; diff --git a/lib/plugin/pl_player/view/view.dart b/lib/plugin/pl_player/view/view.dart index db07a407d4..a6e16bec29 100644 --- a/lib/plugin/pl_player/view/view.dart +++ b/lib/plugin/pl_player/view/view.dart @@ -52,9 +52,9 @@ import 'package:PiliPlus/plugin/pl_player/widgets/common_btn.dart'; import 'package:PiliPlus/plugin/pl_player/widgets/forward_seek.dart'; import 'package:PiliPlus/plugin/pl_player/widgets/mpv_convert_webp.dart'; import 'package:PiliPlus/plugin/pl_player/widgets/play_pause_btn.dart'; +import 'package:PiliPlus/services/playback_stats_service.dart'; import 'package:PiliPlus/utils/android/bindings.g.dart'; import 'package:PiliPlus/utils/cache_manager.dart'; -import 'package:PiliPlus/utils/connectivity_utils.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/extension/num_ext.dart'; import 'package:PiliPlus/utils/extension/theme_ext.dart'; @@ -359,10 +359,12 @@ class _PLVideoPlayerState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { + if (const [.paused, .detached].contains(state)) { + unawaited(PlaybackStatsService.flush()); + } final player = plPlayerController.videoPlayerController; if (player == null) return; - if (state != AppLifecycleState.paused && - state != AppLifecycleState.detached) { + if (state != AppLifecycleState.paused && state != AppLifecycleState.detached) { plPlayerController.autoResumeInPipIfNeeded(); } if (!plPlayerController.continuePlayInBackground.value) { @@ -881,7 +883,7 @@ class _PLVideoPlayerState extends State height: 35, padding: const EdgeInsets.only(left: 15, right: 10), value: item.quality, - onTap: () async { + onTap: () { if (currentVideoQa.code == item.quality) { return; } @@ -897,9 +899,11 @@ class _PLVideoPlayerState extends State // update if (!plPlayerController.tempPlayerConf) { GStorage.setting.put( - await ConnectivityUtils.isWiFi - ? SettingBoxKey.defaultVideoQa - : SettingBoxKey.defaultVideoQaCellular, + plPlayerController.playbackNetworkProfile + ?.useCellularPreferences == + true + ? SettingBoxKey.defaultVideoQaCellular + : SettingBoxKey.defaultVideoQa, quality, ); } @@ -1258,17 +1262,32 @@ class _PLVideoPlayerState extends State } LongPressGestureRecognizer? _longPressRecognizer; + int _longPressSpeedStep = 0; LongPressGestureRecognizer get longPressRecognizer => _longPressRecognizer ??= LongPressGestureRecognizer( duration: plPlayerController.enableTapDm ? const Duration(milliseconds: 300) : null, ) - ..onLongPressStart = ((_) => - plPlayerController.setLongPressStatus(true)) - ..onLongPressEnd = ((_) => plPlayerController.setLongPressStatus(false)) - ..onLongPressCancel = (() => - plPlayerController.setLongPressStatus(false)); + ..onLongPressStart = ((_) { + _longPressSpeedStep = 0; + plPlayerController.setLongPressStatus(true); + }) + ..onLongPressMoveUpdate = ((details) { + if (!plPlayerController.enableLongPressSlideSpeed) return; + final step = (-details.offsetFromOrigin.dy / 24).truncate(); + if (step == _longPressSpeedStep) return; + plPlayerController.adjustLongPressSpeed(step - _longPressSpeedStep); + _longPressSpeedStep = step; + }) + ..onLongPressEnd = ((_) { + _longPressSpeedStep = 0; + plPlayerController.setLongPressStatus(false); + }) + ..onLongPressCancel = (() { + _longPressSpeedStep = 0; + plPlayerController.setLongPressStatus(false); + }); late final ImmediateTapGestureRecognizer _tapGestureRecognizer; late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer; late final PlayerScaleGestureRecognizer _scaleGestureRecognizer; @@ -1511,7 +1530,7 @@ class _PLVideoPlayerState extends State ), child: Obx( () => Text( - '${plPlayerController.enableAutoLongPressSpeed ? (plPlayerController.longPressStatus.value ? plPlayerController.lastPlaybackSpeed : plPlayerController.playbackSpeed) * 2 : plPlayerController.longPressSpeed}倍速中', + '${plPlayerController.playbackSpeed}倍速中', style: const TextStyle( color: Colors.white, fontSize: 13, diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 424e790f44..8af84f2774 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -57,6 +57,8 @@ import 'package:PiliPlus/pages/setting/pages/color_select.dart'; import 'package:PiliPlus/pages/setting/pages/display_mode.dart'; import 'package:PiliPlus/pages/setting/pages/font_setting.dart'; import 'package:PiliPlus/pages/setting/pages/logs.dart'; +import 'package:PiliPlus/pages/setting/pages/network_policy.dart'; +import 'package:PiliPlus/pages/setting/pages/playback_stats.dart'; import 'package:PiliPlus/pages/setting/pages/play_speed_set.dart'; import 'package:PiliPlus/pages/setting/view.dart'; import 'package:PiliPlus/pages/settings_search/view.dart'; @@ -119,6 +121,8 @@ class Routes { // 历史记录搜索 GetPage(name: '/playSpeedSet', page: () => const PlaySpeedPage()), + GetPage(name: '/networkPolicy', page: () => const NetworkPolicyPage()), + GetPage(name: '/playbackStats', page: () => const PlaybackStatsPage()), // 收藏搜索 GetPage(name: '/favSearch', page: () => const FavSearchPage()), GetPage(name: '/historySearch', page: () => const HistorySearchPage()), diff --git a/lib/services/playback_stats_service.dart b/lib/services/playback_stats_service.dart new file mode 100644 index 0000000000..aef26c5a69 --- /dev/null +++ b/lib/services/playback_stats_service.dart @@ -0,0 +1,707 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' show max, min; + +import 'package:PiliPlus/utils/storage.dart'; +import 'package:PiliPlus/utils/storage_key.dart'; +import 'package:flutter/widgets.dart'; + +abstract final class PlaybackStatsService { + static const schemaVersion = 2; + static const _seekThresholdUs = 1000000; + static const _rewindEligibilityUs = 5000000; + + static final _clock = Stopwatch()..start(); + static Map? _stats; + static Timer? _flushTimer; + static Future _writeChain = Future.value(); + static bool _dirty = false; + static AppLifecycleListener? _appLifecycleListener; + static bool _appForeground = false; + static int _appLastWallUs = 0; + + static bool _active = false; + static bool _live = false; + static bool _playing = false; + static bool _buffering = false; + static bool _completedIdle = false; + static String? _mediaKey; + static String? _liveUid; + static int _lastWallUs = 0; + static int _lastPositionUs = 0; + static int _suppressDiscontinuityUntilUs = 0; + static int? _pendingPositionUs; + static double _rate = 1; + static double _defaultRate = 1; + static _RewindEpisode? _rewind; + + static void initializeAppLifecycle() { + _ensureInitialized(); + if (_appLifecycleListener != null) return; + _appLastWallUs = _clock.elapsedMicroseconds; + _appForeground = WidgetsBinding.instance.lifecycleState == .resumed; + _appLifecycleListener = AppLifecycleListener( + onStateChange: (state) { + final now = _clock.elapsedMicroseconds; + _settleAppForeground(now); + _appForeground = state == .resumed; + if (!_appForeground) unawaited(flush()); + }, + ); + } + + static void _ensureInitialized() { + if (_stats != null) return; + final raw = GStorage.video.get(VideoBoxKey.playbackStats); + _stats = raw is Map + ? _stringMap(raw) + : { + 'schemaVersion': schemaVersion, + 'createdAtMs': DateTime.now().millisecondsSinceEpoch, + }; + _stats!['schemaVersion'] = schemaVersion; + _flushTimer ??= Timer.periodic( + const Duration(seconds: 30), + (_) => flush(), + ); + } + + static Map _stringMap(Map raw) => raw.map( + (key, value) => MapEntry( + key.toString(), + value is Map ? _stringMap(value) : value, + ), + ); + + static void _add(String key, num value) { + if (value == 0) return; + final current = _stats![key] as num? ?? 0; + _stats![key] = value is double || current is double + ? current.toDouble() + value + : current.toInt() + value.toInt(); + _dirty = true; + } + + static void _settleAppForeground(int now) { + if (_appForeground) _add('appForegroundUs', max(0, now - _appLastWallUs)); + _appLastWallUs = now; + } + + static Map _map(String key) { + final current = _stats![key]; + if (current is Map) return current; + final value = current is Map ? _stringMap(current) : {}; + _stats![key] = value; + return value; + } + + static void _addBucket(String key, String bucket, num value) { + if (value == 0) return; + final map = _map(key); + final current = map[bucket] as num? ?? 0; + map[bucket] = value is double || current is double + ? current.toDouble() + value + : current.toInt() + value.toInt(); + _dirty = true; + } + + static String _speedKey(double speed) { + final text = speed.toStringAsFixed(2); + return text + .replaceFirst(RegExp(r'0+$'), '') + .replaceFirst(RegExp(r'\.$'), ''); + } + + static void _recordSourceSpeed(double speed, double defaultSpeed) { + final key = _speedKey(speed); + _addBucket('sourceSpeedSelections', key, 1); + if ((speed - defaultSpeed).abs() < 0.001) { + _addBucket('defaultSpeedSelections', key, 1); + } + _stats!['lastSelectedSpeed'] = speed; + _dirty = true; + } + + static void openMedia({ + required bool isLive, + required Duration previousPosition, + required Duration initialPosition, + required double speed, + required double defaultSpeed, + int? cid, + int? liveUid, + }) { + _ensureInitialized(); + final now = _clock.elapsedMicroseconds; + if (_active) { + if (_live) { + _settleLive(now); + } else { + _settleVideo(previousPosition.inMicroseconds, now); + } + } + + final nextKey = isLive + ? 'live:${liveUid ?? 'unknown'}' + : cid == null + ? null + : 'cid:$cid'; + if (nextKey == null) { + _finishRewind(now, completed: false); + _active = false; + _mediaKey = null; + return; + } + + final sameMedia = _active && _live == isLive && _mediaKey == nextKey; + if (!sameMedia) { + _finishRewind(now, completed: false); + if (isLive) { + _liveUid = liveUid?.toString() ?? 'unknown'; + _add('liveOpenCount', 1); + final byUid = _map('liveByUid'); + final item = byUid[_liveUid] is Map + ? _stringMap(byUid[_liveUid] as Map) + : {}; + item['openCount'] = ((item['openCount'] as num?)?.toInt() ?? 0) + 1; + byUid[_liveUid!] = item; + } else { + _liveUid = null; + _add('videoStarts', 1); + _recordSourceSpeed(speed, defaultSpeed); + } + } + + _active = true; + _live = isLive; + _mediaKey = nextKey; + _playing = false; + _buffering = true; + _completedIdle = false; + _rate = speed; + _defaultRate = defaultSpeed; + _lastWallUs = now; + _lastPositionUs = initialPosition.inMicroseconds; + _pendingPositionUs = _lastPositionUs; + _suppressDiscontinuityUntilUs = now + _rewindEligibilityUs; + } + + static void samplePosition(Duration position) { + _ensureInitialized(); + if (!_active || _live) return; + _settleVideo(position.inMicroseconds, _clock.elapsedMicroseconds); + } + + static void updatePlaying(bool playing, Duration position) { + _ensureInitialized(); + final now = _clock.elapsedMicroseconds; + if (_active) { + if (_live) { + _settleLive(now); + } else { + _settleVideo(position.inMicroseconds, now); + } + if (playing != _playing) { + if (playing) { + _add('playbackSegments', 1); + } else if (!_completedIdle) { + _add('pauseCount', 1); + } + } + } + if (playing) _completedIdle = false; + _playing = playing; + } + + static void updateBuffering(bool buffering, Duration position) { + _ensureInitialized(); + final now = _clock.elapsedMicroseconds; + if (_active) { + if (_live) { + _settleLive(now); + } else { + _settleVideo(position.inMicroseconds, now); + } + if (buffering && !_buffering) _add('bufferingCount', 1); + } + _buffering = buffering; + } + + static void changeSpeed( + double speed, + Duration position, { + required bool recordSelection, + }) { + _ensureInitialized(); + if (_active && !_live) { + _settleVideo(position.inMicroseconds, _clock.elapsedMicroseconds); + if (recordSelection) { + final key = _speedKey(speed); + _addBucket('manualSpeedSelections', key, 1); + _stats!['lastSelectedSpeed'] = speed; + _add('rateChangeCount', 1); + _dirty = true; + } + } + _rate = speed; + } + + static void seek( + Duration from, + Duration to, { + required bool userInitiated, + }) { + _ensureInitialized(); + if (!_active || _live) return; + final now = _clock.elapsedMicroseconds; + _settleVideo(from.inMicroseconds, now); + _completedIdle = false; + if (userInitiated) { + _recordSeek(from.inMicroseconds, to.inMicroseconds, now); + } else { + _finishRewind(now, completed: false); + } + _lastPositionUs = to.inMicroseconds; + _lastWallUs = now; + _pendingPositionUs = _lastPositionUs; + _suppressDiscontinuityUntilUs = now + 2000000; + } + + static void rebase(Duration position) { + _ensureInitialized(); + if (!_active || _live) return; + final now = _clock.elapsedMicroseconds; + _settleVideo(position.inMicroseconds, now); + _completedIdle = false; + _lastPositionUs = position.inMicroseconds; + _lastWallUs = now; + _pendingPositionUs = _lastPositionUs; + _suppressDiscontinuityUntilUs = now + 2000000; + } + + static void markCompleted(Duration position) { + _ensureInitialized(); + if (!_active || _live || _completedIdle) return; + _settleVideo(position.inMicroseconds, _clock.elapsedMicroseconds); + _add('completedVideos', 1); + _completedIdle = true; + } + + static void endMedia(Duration position) { + _ensureInitialized(); + if (!_active) return; + final now = _clock.elapsedMicroseconds; + if (_live) { + _settleLive(now); + } else { + _settleVideo(position.inMicroseconds, now); + } + _finishRewind(now, completed: false); + _active = false; + _mediaKey = null; + _liveUid = null; + _pendingPositionUs = null; + unawaited(flush()); + } + + static void endMediaIfMatches({ + required bool isLive, + required Duration position, + int? cid, + int? liveUid, + }) { + _ensureInitialized(); + final key = isLive + ? 'live:${liveUid ?? 'unknown'}' + : cid == null + ? null + : 'cid:$cid'; + if (_active && _live == isLive && _mediaKey == key) { + endMedia(position); + } + } + + static void _settleLive(int now) { + if (!_active || !_live) return; + final elapsed = max(0, now - _lastWallUs); + if (elapsed > 0) { + _add('liveWatchUs', elapsed); + final uid = _liveUid; + if (uid != null) { + final byUid = _map('liveByUid'); + final item = byUid[uid] is Map + ? _stringMap(byUid[uid] as Map) + : {}; + item['watchUs'] = ((item['watchUs'] as num?) ?? 0).toInt() + elapsed; + byUid[uid] = item; + _dirty = true; + } + } + _lastWallUs = now; + } + + static void _settleVideo(int positionUs, int now) { + if (!_active || _live) return; + final wallUs = max(0, now - _lastWallUs); + final mediaDeltaUs = positionUs - _lastPositionUs; + if (_pendingPositionUs case final target?) { + if (now >= _suppressDiscontinuityUntilUs) { + _pendingPositionUs = null; + } else if ((positionUs - target).abs() > _seekThresholdUs) { + // Some backends briefly publish the pre-seek position. Do not turn + // that stale sample into a completed rewind or a second seek. + _lastWallUs = now; + return; + } else { + _pendingPositionUs = null; + } + } + + if (_completedIdle) { + // Waiting on an already completed video is neither playback nor pause. + } else if (_buffering) { + _add('bufferingUs', wallUs); + final rewind = _rewind; + if (rewind == null) { + _add('normalBufferingUs', wallUs); + _addBucket('normalSpeedBufferingUs', _speedKey(_rate), wallUs); + } else { + _add('rewindBufferingUs', wallUs); + _addBucket('rewindSpeedBufferingUs', _speedKey(_rate), wallUs); + rewind.bufferingUs += wallUs; + } + } else if (_playing) { + _add('activePlaybackUs', wallUs); + _add('nominalMediaUs', wallUs * _rate); + _addBucket('speedActiveUs', _speedKey(_rate), wallUs); + + final expectedUs = wallUs * _rate; + final toleranceUs = max(2000000, expectedUs * 2).round(); + final discontinuity = + mediaDeltaUs < -_seekThresholdUs || + mediaDeltaUs > expectedUs + toleranceUs; + int mediaAdvanceUs; + var rewindPlaybackUs = 0; + var rewindMediaAdvanceUs = 0; + if (discontinuity) { + mediaAdvanceUs = now < _suppressDiscontinuityUntilUs + ? 0 + : min(max(0, mediaDeltaUs), expectedUs.round()); + final rewindPart = _advanceRewind( + _lastPositionUs, + _lastPositionUs + mediaAdvanceUs, + wallUs, + now, + ); + rewindPlaybackUs = rewindPart.playbackUs; + rewindMediaAdvanceUs = rewindPart.mediaAdvanceUs; + if (now >= _suppressDiscontinuityUntilUs) { + _add('detectedDiscontinuityCount', 1); + _recordSeek(_lastPositionUs, positionUs, now); + } + } else { + mediaAdvanceUs = max(0, mediaDeltaUs); + final rewindPart = _advanceRewind( + _lastPositionUs, + positionUs, + wallUs, + now, + ); + rewindPlaybackUs = rewindPart.playbackUs; + rewindMediaAdvanceUs = rewindPart.mediaAdvanceUs; + } + _add('mediaAdvanceUs', mediaAdvanceUs); + _addBucket('speedMediaAdvanceUs', _speedKey(_rate), mediaAdvanceUs); + _add('rewindPlaybackUs', rewindPlaybackUs); + _add('rewindMediaAdvanceUs', rewindMediaAdvanceUs); + _add('normalPlaybackUs', wallUs - rewindPlaybackUs); + _add('normalMediaAdvanceUs', mediaAdvanceUs - rewindMediaAdvanceUs); + _addBucket( + 'rewindSpeedActiveUs', + _speedKey(_rate), + rewindPlaybackUs, + ); + _addBucket( + 'rewindSpeedMediaAdvanceUs', + _speedKey(_rate), + rewindMediaAdvanceUs, + ); + _addBucket( + 'normalSpeedActiveUs', + _speedKey(_rate), + wallUs - rewindPlaybackUs, + ); + _addBucket( + 'normalSpeedMediaAdvanceUs', + _speedKey(_rate), + mediaAdvanceUs - rewindMediaAdvanceUs, + ); + } else { + _add('pausedUs', wallUs); + final rewind = _rewind; + if (rewind == null) { + _add('normalPausedUs', wallUs); + _addBucket('normalSpeedPausedUs', _speedKey(_rate), wallUs); + } else { + _add('rewindPausedUs', wallUs); + _addBucket('rewindSpeedPausedUs', _speedKey(_rate), wallUs); + rewind.pausedUs += wallUs; + } + } + + _lastWallUs = now; + _lastPositionUs = positionUs; + } + + static void _recordSeek(int fromUs, int toUs, int now) { + final delta = toUs - fromUs; + if (delta.abs() < _seekThresholdUs) return; + _finishRewind(now, completed: false); + if (delta > 0) { + _add('forwardSeekCount', 1); + _add('forwardSeekUs', delta); + return; + } + + final distance = -delta; + _add('rewindCount', 1); + _add('rewindUs', distance); + if (_rate > 1) { + _add('fastRewindCount', 1); + _add('fastRewindUs', distance); + } + _rewind = _RewindEpisode( + checkpointUs: fromUs, + distanceUs: distance, + startedAtUs: now, + ); + } + + static ({int playbackUs, int mediaAdvanceUs}) _advanceRewind( + int fromUs, + int toUs, + int activeWallUs, + int now, + ) { + final rewind = _rewind; + if (rewind == null) return (playbackUs: 0, mediaAdvanceUs: 0); + if (fromUs >= rewind.checkpointUs) { + _finishRewind(now, completed: true); + return (playbackUs: 0, mediaAdvanceUs: 0); + } + final mediaAdvanceUs = max(0, toUs - fromUs); + if (toUs >= rewind.checkpointUs && toUs > fromUs) { + final fraction = (rewind.checkpointUs - fromUs) / max(1, toUs - fromUs); + final playbackUs = (activeWallUs * fraction.clamp(0, 1)).round(); + final rewindMediaUs = rewind.checkpointUs - fromUs; + rewind + ..activePlaybackUs += playbackUs + ..mediaAdvanceUs += rewindMediaUs; + _finishRewind(now, completed: true); + return (playbackUs: playbackUs, mediaAdvanceUs: rewindMediaUs); + } else { + rewind + ..activePlaybackUs += activeWallUs + ..mediaAdvanceUs += mediaAdvanceUs; + return (playbackUs: activeWallUs, mediaAdvanceUs: mediaAdvanceUs); + } + } + + static void _finishRewind(int now, {required bool completed}) { + final rewind = _rewind; + if (rewind == null) return; + final eligible = + completed || now - rewind.startedAtUs >= _rewindEligibilityUs; + if (eligible) { + _add('eligibleRewindCount', 1); + if (completed) { + _add('completedRewindCount', 1); + _add('completedRewindUs', rewind.distanceUs); + _add('completedRewindPlaybackUs', rewind.activePlaybackUs); + _add('completedRewindMediaAdvanceUs', rewind.mediaAdvanceUs); + _add('completedRewindPausedUs', rewind.pausedUs); + _add('completedRewindBufferingUs', rewind.bufferingUs); + } else { + _add('abandonedRewindCount', 1); + _add('abandonedRewindUs', rewind.distanceUs); + _add('abandonedRewindPlaybackUs', rewind.activePlaybackUs); + _add('abandonedRewindMediaAdvanceUs', rewind.mediaAdvanceUs); + _add('abandonedRewindPausedUs', rewind.pausedUs); + _add('abandonedRewindBufferingUs', rewind.bufferingUs); + } + } else { + _add('shortRewindExitCount', 1); + _add('shortRewindUs', rewind.distanceUs); + _add('shortRewindPlaybackUs', rewind.activePlaybackUs); + _add('shortRewindMediaAdvanceUs', rewind.mediaAdvanceUs); + _add('shortRewindPausedUs', rewind.pausedUs); + _add('shortRewindBufferingUs', rewind.bufferingUs); + } + _rewind = null; + _pendingPositionUs = null; + } + + static Map snapshot() { + _ensureInitialized(); + _settleAppForeground(_clock.elapsedMicroseconds); + if (_active && _live) _settleLive(_clock.elapsedMicroseconds); + final raw = jsonDecode(jsonEncode(_stats!)) as Map; + final source = _numericMap(raw['sourceSpeedSelections']); + final manual = _numericMap(raw['manualSpeedSelections']); + final selections = {...source}; + for (final entry in manual.entries) { + selections.update( + entry.key, + (value) => value + entry.value, + ifAbsent: () => entry.value, + ); + } + final maxCount = selections.values.fold(0, max); + final last = (raw['lastSelectedSpeed'] as num?)?.toDouble(); + final favorite = selections.entries + .where((entry) => entry.value == maxCount) + .map((entry) => double.tryParse(entry.key)) + .whereType() + .fold(null, (value, speed) { + if (last != null && (speed - last).abs() < 0.001) return speed; + return value ?? speed; + }); + + final active = _number(raw['activePlaybackUs']); + final media = _number(raw['mediaAdvanceUs']); + final nominal = _number(raw['nominalMediaUs']); + final eligible = _number(raw['eligibleRewindCount']); + final completed = _number(raw['completedRewindCount']); + final rewindMedia = _number(raw['completedRewindUs']); + final rewindWall = _number(raw['completedRewindPlaybackUs']); + final normalWall = _number(raw['normalPlaybackUs']); + final normalMedia = _number(raw['normalMediaAdvanceUs']); + final allRewindWall = _number(raw['rewindPlaybackUs']); + final allRewindMedia = _number(raw['rewindMediaAdvanceUs']); + final rewindObserved = + allRewindWall + + _number(raw['rewindPausedUs']) + + _number(raw['rewindBufferingUs']); + final completedRewindObserved = + rewindWall + + _number(raw['completedRewindPausedUs']) + + _number(raw['completedRewindBufferingUs']); + raw['derived'] = { + 'favoriteSpeed': favorite, + 'favoriteSpeedWasDefault': + favorite != null && + (_numericMap( + raw['defaultSpeedSelections'], + )[_speedKey(favorite)] ?? + 0) > + 0, + 'actualAverageSpeed': active == 0 ? 0 : media / active, + 'nominalAverageSpeed': active == 0 ? 0 : nominal / active, + 'savedTimeUs': media - active, + 'normalSavedTimeUs': normalMedia - normalWall, + 'rewindSavedTimeUs': allRewindMedia - allRewindWall, + 'rewindCompletionRate': eligible == 0 ? 0 : completed / eligible, + 'rewindEquivalentSpeed': rewindWall == 0 ? 0 : rewindMedia / rewindWall, + 'rewindObservedEquivalentSpeed': completedRewindObserved == 0 + ? 0 + : rewindMedia / completedRewindObserved, + 'normalObservedUs': + normalWall + + _number(raw['normalPausedUs']) + + _number(raw['normalBufferingUs']), + 'rewindObservedUs': rewindObserved, + 'playerObservedUs': + active + _number(raw['pausedUs']) + _number(raw['bufferingUs']), + 'speedSelectionCounts': selections, + }; + return raw; + } + + static Map _numericMap(dynamic raw) { + if (raw is! Map) return {}; + return raw.map( + (key, value) => MapEntry(key.toString(), value is num ? value : 0), + ); + } + + static num _number(dynamic value) => value is num ? value : 0; + + static String advancedJson() { + final data = snapshot(); + final derived = data.remove('derived'); + return const JsonEncoder.withIndent(' ').convert({ + '说明': { + 'schemaVersion': schemaVersion, + 'timeUnit': 'microseconds', + 'savedTime': 'mediaAdvanceUs - activePlaybackUs;不包含快进跳转', + 'normalSavedTime': 'normalMediaAdvanceUs - normalPlaybackUs', + 'rewindSavedTime': 'rewindMediaAdvanceUs - rewindPlaybackUs', + 'actualAverageSpeed': 'mediaAdvanceUs / activePlaybackUs', + 'appForegroundTime': 'appForegroundUs;使用单调时钟累计应用处于前台的时间', + 'rewindEquivalentSpeed': + 'completedRewindUs / completedRewindPlaybackUs;分子为原视频时间,分母为实际播放时间', + 'rewindObservedEquivalentSpeed': 'completedRewindUs /(completedRewindPlaybackUs + completedRewindPausedUs + completedRewindBufferingUs);用于回看包含暂停与缓冲在内的真实时间成本', + 'rewindCompletionRate': + 'completedRewindCount / eligibleRewindCount;完成或倒带后停留至少 5 秒才进入分母', + 'stateAxes': '普通观看/倒带重看 × 播放/暂停/缓冲 × 当前倍速;快进跳转、倒带结果和直播另行保存', + }, + '原语': data, + '当前推导值': derived, + }); + } + + static Future flush() async { + _ensureInitialized(); + _settleAppForeground(_clock.elapsedMicroseconds); + if (_active && _live) _settleLive(_clock.elapsedMicroseconds); + if (!_dirty) return; + _stats!['updatedAtMs'] = DateTime.now().millisecondsSinceEpoch; + _dirty = false; + final value = jsonDecode(jsonEncode(_stats!)); + _writeChain = _writeChain.then( + (_) => GStorage.video.put(VideoBoxKey.playbackStats, value), + onError: (_) => GStorage.video.put(VideoBoxKey.playbackStats, value), + ); + await _writeChain; + } + + static Future reset() async { + _ensureInitialized(); + _stats = { + 'schemaVersion': schemaVersion, + 'createdAtMs': DateTime.now().millisecondsSinceEpoch, + }; + _rewind = null; + _pendingPositionUs = null; + _dirty = true; + _appLastWallUs = _clock.elapsedMicroseconds; + if (_active) { + if (_live) { + _add('liveOpenCount', 1); + final uid = _liveUid ?? 'unknown'; + _map('liveByUid')[uid] = {'openCount': 1, 'watchUs': 0}; + } else { + _add('videoStarts', 1); + _recordSourceSpeed(_rate, _defaultRate); + } + _lastWallUs = _clock.elapsedMicroseconds; + } + await flush(); + } +} + +final class _RewindEpisode { + _RewindEpisode({ + required this.checkpointUs, + required this.distanceUs, + required this.startedAtUs, + }); + + final int checkpointUs; + final int distanceUs; + final int startedAtUs; + int activePlaybackUs = 0; + int mediaAdvanceUs = 0; + int pausedUs = 0; + int bufferingUs = 0; +} diff --git a/lib/utils/android/android_helper.dart b/lib/utils/android/android_helper.dart index 6528e78213..21f3e66482 100644 --- a/lib/utils/android/android_helper.dart +++ b/lib/utils/android/android_helper.dart @@ -7,6 +7,14 @@ library; abstract final class PiliAndroidHelper { static void back() {} + static ({ + int? linkSpeed, + int? rssi, + int? signalLevel, + bool metered, + bool weakHint, + })? networkInfo() => null; + static void biliSendCommAntifraud( int action, int oid, diff --git a/lib/utils/connectivity_utils.dart b/lib/utils/connectivity_utils.dart index 44639afb49..e8aa722c37 100644 --- a/lib/utils/connectivity_utils.dart +++ b/lib/utils/connectivity_utils.dart @@ -1,15 +1,268 @@ -import 'package:PiliPlus/utils/platform_utils.dart'; +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:PiliPlus/models/common/network_profile.dart'; +import 'package:PiliPlus/models/common/video/video_decode_type.dart'; +import 'package:PiliPlus/utils/android/android_helper.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:PiliPlus/utils/windows/network_info.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; abstract final class ConnectivityUtils { - static Future get isWiFi async { + static final _changes = StreamController.broadcast( + sync: true, + ); + static Stream get changes => _changes.stream; + + // App-lifetime observer; ConnectivityUtils lives for the whole process. + // ignore: cancel_subscriptions + static StreamSubscription? _subscription; + static Timer? _networkDebounce; + static Timer? _peakTimer; + static NetworkProfile? _current; + static bool _bufferingWeak = false; + static bool _peakActive = false; + + static NetworkProfile? get current => _current; + + static Future initialize() async { + if (_subscription != null) return; + _current = await _sample(); + _peakActive = isNetworkPeak(_current!); + _schedulePeakBoundary(); + _subscription = Connectivity().onConnectivityChanged.skip(1).listen((_) { + _networkDebounce?.cancel(); + _networkDebounce = Timer(const Duration(milliseconds: 800), () { + _bufferingWeak = false; + _refresh(); + }); + }); + } + + static Future resolveForPlayback() async { + if (_subscription == null) await initialize(); + return _refresh(); + } + + static Future get isWiFi async => + !(await resolveForPlayback()).useCellularPreferences; + + static Future _refresh({ + bool forcePeakRefresh = false, + }) async { + final next = await _sample(); + final previous = _current; + final peak = isNetworkPeak(next); + final peakChanged = peak != _peakActive; + _current = next; + _peakActive = peak; + if (previous != null && previous.identity != next.identity) { + _changes.add(NetworkPolicyChange(next, NetworkPolicyReason.network)); + } else if (peakChanged || forcePeakRefresh && peak) { + _changes.add(NetworkPolicyChange(next, NetworkPolicyReason.peak)); + } + _schedulePeakBoundary(); + return next; + } + + static Future notifySettingsChanged() async { + if (_subscription == null) await initialize(); + await _refresh(forcePeakRefresh: true); + } + + static void markCurrentWifiWeak() { + if (!Platform.isAndroid || + _current?.transport != NetworkTransport.wifi || + !Pref.wifiNetworkPolicy || + Pref.wifiRssiThreshold != 0 || + _bufferingWeak) { + return; + } + _bufferingWeak = true; + _refresh(); + } + + static List effectiveCodecs( + List base, + NetworkProfile profile, + ) { + if (!isNetworkPeak(profile)) return List.of(base); + final peak = Pref.networkPeakCodecs; + return List.of(peak.isEmpty ? Pref.preferCodecsCellular : peak); + } + + static bool isNetworkPeak(NetworkProfile profile, [DateTime? now]) { + final time = now ?? DateTime.now(); + final currentMinute = time.hour * 60 + time.minute; + for (final period in Pref.networkPeakPeriods) { + if (period['enabled'] != true) continue; + final start = period['start'] as int?; + final end = period['end'] as int?; + if (start == null || end == null) continue; + final inPeriod = start == end + ? true + : start < end + ? currentMinute >= start && currentMinute < end + : currentMinute >= start || currentMinute < end; + if (inPeriod && + (period['scope'] == 1 || !profile.useCellularPreferences)) { + return true; + } + } + return false; + } + + static void _schedulePeakBoundary() { + _peakTimer?.cancel(); + final now = DateTime.now(); + DateTime? next; + for (final period in Pref.networkPeakPeriods) { + if (period['enabled'] != true) continue; + for (final value in [period['start'], period['end']]) { + if (value is! int) continue; + var candidate = DateTime( + now.year, + now.month, + now.day, + value ~/ 60, + value % 60, + ); + if (!candidate.isAfter(now)) { + candidate = candidate.add(const Duration(days: 1)); + } + if (next == null || candidate.isBefore(next)) next = candidate; + } + } + if (next != null) { + _peakTimer = Timer( + next.difference(now) + const Duration(milliseconds: 50), + () { + final profile = _current; + if (profile != null) { + final active = isNetworkPeak(profile); + if (active != _peakActive) { + _peakActive = active; + _changes.add( + NetworkPolicyChange(profile, NetworkPolicyReason.peak), + ); + } + } + _schedulePeakBoundary(); + }, + ); + } + } + + static Future _sample() async { try { - // connectivity_plus 5.x 返回单值(鸿蒙适配版本),非上游 7.x 的 List - // 鸿蒙上 checkConnectivity 可能抛异常,catch 后按 wifi 处理 - return PlatformUtils.isMobile && - (await Connectivity().checkConnectivity()) == ConnectivityResult.wifi; + final connectivity = await Connectivity().checkConnectivity(); + if (connectivity == ConnectivityResult.none) { + return _current ?? _fallback; + } + + if (connectivity == ConnectivityResult.wifi) { + int? rssi; + int? linkSpeed; + int? signalLevel; + var metered = false; + var weakHint = _bufferingWeak; + if (Platform.isWindows) { + final info = WindowsNetworkInfoReader.read(); + rssi = info.rssi; + linkSpeed = info.wifiMbps; + } else if (Platform.isAndroid) { + final info = PiliAndroidHelper.networkInfo(); + rssi = info?.rssi; + linkSpeed = info?.linkSpeed; + signalLevel = info?.signalLevel; + metered = info?.metered ?? false; + weakHint = weakHint || (info?.weakHint ?? false); + } + + final signalWeak = Platform.isAndroid && Pref.wifiRssiThreshold == 0 + ? (signalLevel != null && signalLevel < 3) || weakHint + : rssi != null && rssi < Pref.wifiRssiThreshold; + final speedWeak = + linkSpeed != null && linkSpeed < Pref.wifiMinLinkSpeed; + final wifiWeak = + Pref.wifiNetworkPolicy && + switch (Pref.wifiNetworkPolicyMode) { + 0 => signalWeak, + 1 => speedWeak, + 2 => signalWeak && speedWeak, + _ => signalWeak || speedWeak, + }; + return NetworkProfile( + transport: NetworkTransport.wifi, + useCellularPreferences: metered || wifiWeak, + rssi: rssi, + linkSpeedMbps: linkSpeed, + signalLevel: signalLevel, + metered: metered, + weakHint: weakHint, + ); + } + + if (connectivity == ConnectivityResult.ethernet) { + final speed = Platform.isWindows + ? WindowsNetworkInfoReader.read().wiredMbps + : null; + final isNonstandard = speed != null && !_isStandardWiredSpeed(speed); + final wiredWeak = + Pref.wiredNetworkPolicy && + speed != null && + (speed < Pref.wiredMinLinkSpeed || + Pref.wiredNonstandardLinkSpeed && isNonstandard); + return NetworkProfile( + transport: NetworkTransport.wired, + useCellularPreferences: wiredWeak, + linkSpeedMbps: speed, + ); + } + + if (connectivity == ConnectivityResult.mobile) { + return const NetworkProfile( + transport: NetworkTransport.cellular, + useCellularPreferences: true, + metered: true, + ); + } + return _current ?? _fallback; } catch (_) { - return true; + return _current ?? _fallback; } } + + static bool _isStandardWiredSpeed(int speed) => _standardWiredSpeeds.any( + (standard) => (speed - standard).abs() <= standard * 0.035, + ); + + // Nominal Ethernet MAC rates from 1BASE5 through 800GbE (and emerging + // 1.6TbE), plus legacy and current PON optical line rates. The tolerance + // also covers 1.244/2.488/9.953/10.3125G and similar reported values. + static const _standardWiredSpeeds = { + 1, + 10, + 100, + 155, + 622, + 1000, + 1250, + 2500, + 5000, + 10000, + 25000, + 40000, + 50000, + 100000, + 200000, + 400000, + 800000, + 1600000, + }; + + static const _fallback = NetworkProfile( + transport: NetworkTransport.other, + useCellularPreferences: false, + ); } diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index 2259bb9654..cf3f7d99fd 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -475,13 +475,10 @@ abstract final class PageUtils { if (off) { Get.offNamed( '/webview', - parameters: { - 'url': url, - ...?parameters, - }, + parameters: {'url': url.http2https, ...?parameters}, ); } else { - PiliScheme.routePushFromUrl(url, parameters: parameters); + PiliScheme.routePushFromUrl(url.http2https, parameters: parameters); } } } diff --git a/lib/utils/storage_key.dart b/lib/utils/storage_key.dart index cbe061093a..66208047a6 100644 --- a/lib/utils/storage_key.dart +++ b/lib/utils/storage_key.dart @@ -49,6 +49,8 @@ abstract final class SettingBoxKey { disableAudioCDN = 'disableAudioCDN', autoPiP = 'autoPiP', enableAutoLongPressSpeed = 'enableAutoLongPressSpeed', + longPressSpeedFactor = 'longPressSpeedFactor', + enableLongPressSlideSpeed = 'enableLongPressSlideSpeed', useRelativeSlide = 'useRelativeSlide', sliderDuration = 'sliderOffset', enableQuickDouble = 'enableQuickDouble', @@ -167,6 +169,16 @@ abstract final class SettingBoxKey { fullScreenModeMigrated = 'fullScreenModeMigrated', liveStream = 'liveStream'; + static const String wiredNetworkPolicy = 'wiredNetworkPolicy', + wiredMinLinkSpeed = 'wiredMinLinkSpeed', + wiredNonstandardLinkSpeed = 'wiredNonstandardLinkSpeed', + wifiNetworkPolicy = 'wifiNetworkPolicy', + wifiNetworkPolicyMode = 'wifiNetworkPolicyMode', + wifiRssiThreshold = 'wifiRssiThreshold', + wifiMinLinkSpeed = 'wifiMinLinkSpeed', + networkPeakPeriods = 'networkPeakPeriods', + networkPeakCodecs = 'networkPeakCodecs'; + static const String minimizeOnExit = 'minimizeOnExit', windowSize = 'windowSize', windowPosition = 'windowPosition', @@ -265,5 +277,6 @@ abstract final class VideoBoxKey { playSpeedDefault = 'playSpeedDefault', longPressSpeedDefault = 'longPressSpeedDefault', speedsList = 'speedsList', + playbackStats = 'playbackStats', cacheVideoFit = 'cacheVideoFit'; } diff --git a/lib/utils/storage_pref.dart b/lib/utils/storage_pref.dart index 1527d12000..3ad6babd21 100644 --- a/lib/utils/storage_pref.dart +++ b/lib/utils/storage_pref.dart @@ -270,7 +270,9 @@ abstract final class Pref { return codecs.map((i) => VideoDecodeFormatType.values.byName(i)).toList(); } if (OS.isHarmony) return const [.HEVC, .AVC]; - return const [.AVC, .AV1]; + return PlatformUtils.isMobile + ? const [.AVC, .HEVC] + : const [.AVC, .AV1]; } static List get preferCodecsCellular { @@ -278,7 +280,48 @@ abstract final class Pref { if (codecs is List) { return codecs.map((i) => VideoDecodeFormatType.values.byName(i)).toList(); } - return preferCodecs; + return const [.HEVC, .AV1]; + } + + static bool get wiredNetworkPolicy => + _setting.get(SettingBoxKey.wiredNetworkPolicy, defaultValue: false); + + static int get wiredMinLinkSpeed => + _setting.get(SettingBoxKey.wiredMinLinkSpeed, defaultValue: 1000); + + static bool get wiredNonstandardLinkSpeed => _setting.get( + SettingBoxKey.wiredNonstandardLinkSpeed, + defaultValue: false, + ); + + static bool get wifiNetworkPolicy => + _setting.get(SettingBoxKey.wifiNetworkPolicy, defaultValue: false); + + static int get wifiNetworkPolicyMode => + _setting.get(SettingBoxKey.wifiNetworkPolicyMode, defaultValue: 0); + + static int get wifiRssiThreshold => + _setting.get(SettingBoxKey.wifiRssiThreshold, defaultValue: -70); + + static int get wifiMinLinkSpeed => + _setting.get(SettingBoxKey.wifiMinLinkSpeed, defaultValue: 100); + + static List> get networkPeakPeriods => + (_setting.get(SettingBoxKey.networkPeakPeriods) as List?) + ?.whereType() + .map(Map.from) + .toList() ?? + const []; + + static List get networkPeakCodecs { + final codecs = _setting.get(SettingBoxKey.networkPeakCodecs); + if (codecs is List) { + return codecs + .whereType() + .map(VideoDecodeFormatType.values.byName) + .toList(); + } + return const []; } static String get hardwareDecoding => _setting.get( @@ -893,6 +936,14 @@ abstract final class Pref { static bool get enableAutoLongPressSpeed => _setting.get(SettingBoxKey.enableAutoLongPressSpeed, defaultValue: false); + static double get longPressSpeedFactor => + _setting.get(SettingBoxKey.longPressSpeedFactor, defaultValue: 2.0); + + static bool get enableLongPressSlideSpeed => _setting.get( + SettingBoxKey.enableLongPressSlideSpeed, + defaultValue: false, + ); + static double get playSpeedDefault => _video.get(VideoBoxKey.playSpeedDefault, defaultValue: 1.0); diff --git a/lib/utils/windows/network_info.dart b/lib/utils/windows/network_info.dart new file mode 100644 index 0000000000..76cc4df86f --- /dev/null +++ b/lib/utils/windows/network_info.dart @@ -0,0 +1,13 @@ +final class WindowsNetworkInfo { + const WindowsNetworkInfo({this.rssi, this.wifiMbps, this.wiredMbps}); + + final int? rssi; + final int? wifiMbps; + final int? wiredMbps; +} + +abstract final class WindowsNetworkInfoReader { + static const _empty = WindowsNetworkInfo(); + + static WindowsNetworkInfo read() => _empty; +}