diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index bb4a899..a585079 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -13,6 +13,7 @@ class PlayTicket { this.durationSec = 0, this.unlockCoin = 0, this.previewSeconds = 0, + this.qualities = const [], }); static const String free = 'FREE'; @@ -35,6 +36,9 @@ class PlayTicket { /// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。 final int previewSeconds; + /// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 + final List qualities; + bool get needsUnlock => accessType == coinRequired || accessType == vipRequired; @@ -50,6 +54,10 @@ class PlayTicket { durationSec: jsonToInt(json['durationSec']), unlockCoin: jsonToInt(json['unlockCoin']), previewSeconds: jsonToInt(json['previewSeconds']), + qualities: (json['qualities'] as List? ?? const []) + .whereType() + .where((quality) => quality.isNotEmpty) + .toList(), ); } } diff --git a/lib/core/data/repositories/playback_repository.dart b/lib/core/data/repositories/playback_repository.dart index 24bc443..fda310e 100644 --- a/lib/core/data/repositories/playback_repository.dart +++ b/lib/core/data/repositories/playback_repository.dart @@ -91,6 +91,7 @@ class FakePlaybackRepository implements PlaybackRepository { accessible: true, accessType: PlayTicket.free, playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc', + qualities: const ['AUTO', '720P', '1080P'], ); } } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 910aff0..e173466 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -72,6 +72,7 @@ class PlayerLogic extends ChangeNotifier { PlayTicket? blockedTicket; String? blockedEpisodeId; List sessionEpisodes = const []; + List qualities = const []; int pageRequest = 0; bool muted = false; double playbackSpeed = 1.0; @@ -217,6 +218,7 @@ class PlayerLogic extends ChangeNotifier { if (localPath != null) { blockedTicket = null; blockedEpisodeId = null; + qualities = const []; sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath)); return true; } @@ -227,6 +229,7 @@ class PlayerLogic extends ChangeNotifier { if (request != pageRequest) return false; blockedTicket = null; blockedEpisodeId = null; + qualities = ticket.qualities; if (ticket.accessible && ticket.playUrl.isNotEmpty) { // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 0528611..51bacbf 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -28,6 +28,7 @@ import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; import 'widgets/player_center_toggle.dart'; +import 'widgets/quality_sheet.dart'; import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -402,6 +403,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, onSpeedTap: () => unawaited(_openSpeedSheet(context)), + qualities: logic.qualities, + onQualityTap: () => unawaited(_openQualitySheet(context)), controlsVisible: _chromeVisible, onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), @@ -500,6 +503,15 @@ class _PlayerPageState extends State with WidgetsBindingObserver { await logic.setPlaybackSpeed(selected); } + Future _openQualitySheet(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (_) => QualitySheet(qualities: logic.qualities), + ); + } + Future _unlockEpisode(BuildContext context, int index) async { if (!await ensureSignedIn(context)) return; if (!context.mounted) return; @@ -693,6 +705,8 @@ class _PlaybackOverlay extends StatefulWidget { required this.playing, required this.playbackSpeed, required this.onSpeedTap, + required this.qualities, + required this.onQualityTap, required this.controlsVisible, required this.onTogglePlay, required this.onClear, @@ -720,6 +734,8 @@ class _PlaybackOverlay extends StatefulWidget { final bool playing; final double playbackSpeed; final VoidCallback onSpeedTap; + final List qualities; + final VoidCallback onQualityTap; final bool controlsVisible; final VoidCallback onTogglePlay; final VoidCallback onClear; @@ -953,7 +969,29 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { }); }, ), - ListTile( + if (widget.qualities.isNotEmpty) + ListTile( + key: const Key('player-more-quality'), + leading: const Icon( + Icons.high_quality_rounded, + color: AppColors.secondary, + ), + title: Text(widget.l10n.playbackQuality), + trailing: Text( + widget.qualities.contains(QualitySheet.autoQuality) + ? widget.l10n.qualityAuto + : widget.qualities.first, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + widget.onQualityTap(); + }, + ), ListTile( key: const Key('player-more-download'), leading: const Icon( Icons.download_outlined, diff --git a/lib/pages/player/widgets/quality_sheet.dart b/lib/pages/player/widgets/quality_sheet.dart new file mode 100644 index 0000000..5f3d69c --- /dev/null +++ b/lib/pages/player/widgets/quality_sheet.dart @@ -0,0 +1,79 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 清晰度档位面板:展示后端下发档位;HLS 多码率由播放器自动切换。 +class QualitySheet extends StatelessWidget { + const QualitySheet({ + super.key, + required this.qualities, + this.selected = autoQuality, + }); + + static const String autoQuality = 'AUTO'; + + final List qualities; + final String selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SafeArea( + child: Column( + key: const Key('player-quality-sheet'), + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackQuality, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + for (final quality in qualities) + ListTile( + key: Key('player-quality-$quality'), + dense: true, + title: Text( + _label(l10n, quality), + style: TextStyle( + color: quality == selected ? AppColors.blue : AppColors.cream, + fontSize: 13, + fontWeight: quality == selected + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + trailing: quality == selected + ? const Icon( + Icons.check_rounded, + color: AppColors.blue, + size: 18, + ) + : null, + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 14), + child: Text( + l10n.qualityAutoHint, + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + ), + ], + ), + ); + } + + String _label(AppLocalizations l10n, String quality) => + quality == autoQuality ? l10n.qualityAuto : quality; +} diff --git a/test/playback/play_ticket_quality_test.dart b/test/playback/play_ticket_quality_test.dart new file mode 100644 index 0000000..00a55c7 --- /dev/null +++ b/test/playback/play_ticket_quality_test.dart @@ -0,0 +1,29 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; + +void main() { + test('should_parse_all_qualities_when_ticket_returns_multiple_tracks', () { + final ticket = PlayTicket.fromJson(const { + 'episodeId': '12', + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': '/files/episode/12.m3u8?e=1&s=abc', + 'qualities': ['AUTO', '720P', '1080P'], + }); + + expect(ticket.qualities, ['AUTO', '720P', '1080P']); + }); + + test('should_default_to_empty_qualities_when_field_is_missing', () { + final ticket = PlayTicket.fromJson(const { + 'episodeId': '12', + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': '/files/episode/12.mp4', + }); + + expect(ticket.qualities, isEmpty); + }); +} diff --git a/test/playback/player_quality_test.dart b/test/playback/player_quality_test.dart new file mode 100644 index 0000000..41e9df8 --- /dev/null +++ b/test/playback/player_quality_test.dart @@ -0,0 +1,44 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; + +import '../support/fake_watch_history.dart'; + +void main() { + test('should_expose_ticket_qualities_when_ticket_is_loaded', () async { + final logic = PlayerLogic( + history: FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: '12', + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/episode/12.m3u8?e=1&s=abc', + qualities: ['AUTO', '720P', '1080P'], + ), + ), + pictureInPicture: AndroidPictureInPicture( + channel: const MethodChannel('test/player_quality/pip'), + ), + ); + logic.sessionEpisodes = [ + Episode( + id: '12', + number: 1, + title: 'Episode 1', + isFree: true, + ), + ]; + + final ready = await logic.ensureTicket(0); + + expect(ready, isTrue); + expect(logic.qualities, ['AUTO', '720P', '1080P']); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 45fc167..b5ecb15 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -38,8 +38,15 @@ void main() { expect(find.byType(PageView), findsOneWidget); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-quality')), findsOneWidget); expect(find.byKey(const Key('player-more-download')), findsOneWidget); expect(find.byKey(const Key('player-more-pip')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-quality'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-quality-sheet')), findsOneWidget); + expect(find.text('720P'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); diff --git a/test/player/quality_sheet_test.dart b/test/player/quality_sheet_test.dart new file mode 100644 index 0000000..d9c8024 --- /dev/null +++ b/test/player/quality_sheet_test.dart @@ -0,0 +1,32 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/quality_sheet.dart'; + +void main() { + testWidgets('should_list_every_returned_quality_when_sheet_opens', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: QualitySheet(qualities: ['AUTO', '720P', '1080P']), + ), + ), + ); + + expect(find.text('Auto'), findsOneWidget); + expect(find.text('720P'), findsOneWidget); + expect(find.text('1080P'), findsOneWidget); + expect(find.byIcon(Icons.check_rounded), findsOneWidget); + expect( + find.text('HLS adjusts the bitrate automatically for your connection.'), + findsOneWidget, + ); + }); +}