feat(app): 播放器增加清晰度档位入口
This commit is contained in:
1 parent
30a5fa0776
commit
2b2b7e9913
9 files changed
+242
-1
No files matched your search
@@ -13,6 +13,7 @@ class PlayTicket {
|
|||||||
this.durationSec = 0,
|
this.durationSec = 0,
|
||||||
this.unlockCoin = 0,
|
this.unlockCoin = 0,
|
||||||
this.previewSeconds = 0,
|
this.previewSeconds = 0,
|
||||||
|
this.qualities = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
static const String free = 'FREE';
|
static const String free = 'FREE';
|
||||||
@@ -35,6 +36,9 @@ class PlayTicket {
|
|||||||
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
||||||
final int previewSeconds;
|
final int previewSeconds;
|
||||||
|
|
||||||
|
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
|
||||||
|
final List<String> qualities;
|
||||||
|
|
||||||
bool get needsUnlock =>
|
bool get needsUnlock =>
|
||||||
accessType == coinRequired || accessType == vipRequired;
|
accessType == coinRequired || accessType == vipRequired;
|
||||||
|
|
||||||
@@ -50,6 +54,10 @@ class PlayTicket {
|
|||||||
durationSec: jsonToInt(json['durationSec']),
|
durationSec: jsonToInt(json['durationSec']),
|
||||||
unlockCoin: jsonToInt(json['unlockCoin']),
|
unlockCoin: jsonToInt(json['unlockCoin']),
|
||||||
previewSeconds: jsonToInt(json['previewSeconds']),
|
previewSeconds: jsonToInt(json['previewSeconds']),
|
||||||
|
qualities: (json['qualities'] as List? ?? const [])
|
||||||
|
.whereType<String>()
|
||||||
|
.where((quality) => quality.isNotEmpty)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,7 @@ class FakePlaybackRepository implements PlaybackRepository {
|
|||||||
accessible: true,
|
accessible: true,
|
||||||
accessType: PlayTicket.free,
|
accessType: PlayTicket.free,
|
||||||
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
|
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
|
||||||
|
qualities: const ['AUTO', '720P', '1080P'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
PlayTicket? blockedTicket;
|
PlayTicket? blockedTicket;
|
||||||
String? blockedEpisodeId;
|
String? blockedEpisodeId;
|
||||||
List<Episode> sessionEpisodes = const [];
|
List<Episode> sessionEpisodes = const [];
|
||||||
|
List<String> qualities = const [];
|
||||||
int pageRequest = 0;
|
int pageRequest = 0;
|
||||||
bool muted = false;
|
bool muted = false;
|
||||||
double playbackSpeed = 1.0;
|
double playbackSpeed = 1.0;
|
||||||
@@ -217,6 +218,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
if (localPath != null) {
|
if (localPath != null) {
|
||||||
blockedTicket = null;
|
blockedTicket = null;
|
||||||
blockedEpisodeId = null;
|
blockedEpisodeId = null;
|
||||||
|
qualities = const [];
|
||||||
sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath));
|
sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -227,6 +229,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
if (request != pageRequest) return false;
|
if (request != pageRequest) return false;
|
||||||
blockedTicket = null;
|
blockedTicket = null;
|
||||||
blockedEpisodeId = null;
|
blockedEpisodeId = null;
|
||||||
|
qualities = ticket.qualities;
|
||||||
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
||||||
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
||||||
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
|
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import 'player_logic.dart';
|
|||||||
import 'widgets/home_feed_overlay.dart';
|
import 'widgets/home_feed_overlay.dart';
|
||||||
import 'widgets/exit_clear_screen_button.dart';
|
import 'widgets/exit_clear_screen_button.dart';
|
||||||
import 'widgets/player_center_toggle.dart';
|
import 'widgets/player_center_toggle.dart';
|
||||||
|
import 'widgets/quality_sheet.dart';
|
||||||
import 'widgets/player_featured_comment.dart';
|
import 'widgets/player_featured_comment.dart';
|
||||||
import 'widgets/seek_progress_bar.dart';
|
import 'widgets/seek_progress_bar.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
@@ -402,6 +403,8 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
playing: logic.session.userWantsPlay,
|
playing: logic.session.userWantsPlay,
|
||||||
playbackSpeed: _playbackSpeed,
|
playbackSpeed: _playbackSpeed,
|
||||||
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
|
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
|
||||||
|
qualities: logic.qualities,
|
||||||
|
onQualityTap: () => unawaited(_openQualitySheet(context)),
|
||||||
controlsVisible: _chromeVisible,
|
controlsVisible: _chromeVisible,
|
||||||
onClear: () => setState(() => _chromeVisible = false),
|
onClear: () => setState(() => _chromeVisible = false),
|
||||||
onExitClear: () => setState(() => _chromeVisible = true),
|
onExitClear: () => setState(() => _chromeVisible = true),
|
||||||
@@ -500,6 +503,15 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
await logic.setPlaybackSpeed(selected);
|
await logic.setPlaybackSpeed(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openQualitySheet(BuildContext context) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppColors.card,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => QualitySheet(qualities: logic.qualities),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _unlockEpisode(BuildContext context, int index) async {
|
Future<void> _unlockEpisode(BuildContext context, int index) async {
|
||||||
if (!await ensureSignedIn(context)) return;
|
if (!await ensureSignedIn(context)) return;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -693,6 +705,8 @@ class _PlaybackOverlay extends StatefulWidget {
|
|||||||
required this.playing,
|
required this.playing,
|
||||||
required this.playbackSpeed,
|
required this.playbackSpeed,
|
||||||
required this.onSpeedTap,
|
required this.onSpeedTap,
|
||||||
|
required this.qualities,
|
||||||
|
required this.onQualityTap,
|
||||||
required this.controlsVisible,
|
required this.controlsVisible,
|
||||||
required this.onTogglePlay,
|
required this.onTogglePlay,
|
||||||
required this.onClear,
|
required this.onClear,
|
||||||
@@ -720,6 +734,8 @@ class _PlaybackOverlay extends StatefulWidget {
|
|||||||
final bool playing;
|
final bool playing;
|
||||||
final double playbackSpeed;
|
final double playbackSpeed;
|
||||||
final VoidCallback onSpeedTap;
|
final VoidCallback onSpeedTap;
|
||||||
|
final List<String> qualities;
|
||||||
|
final VoidCallback onQualityTap;
|
||||||
final bool controlsVisible;
|
final bool controlsVisible;
|
||||||
final VoidCallback onTogglePlay;
|
final VoidCallback onTogglePlay;
|
||||||
final VoidCallback onClear;
|
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'),
|
key: const Key('player-more-download'),
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
Icons.download_outlined,
|
Icons.download_outlined,
|
||||||
|
|||||||
@@ -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<String> 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,8 +38,15 @@ void main() {
|
|||||||
expect(find.byType(PageView), findsOneWidget);
|
expect(find.byType(PageView), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const Key('player-more')));
|
await tester.tap(find.byKey(const Key('player-more')));
|
||||||
await tester.pumpAndSettle();
|
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-download')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('player-more-pip')), 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);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user