Skip to content

Commit e178edb

Browse files
committed
FIX: 修复讲解页翻译与播放器间距
- 收紧讲解页原句与翻译间距 - 收紧播放器底部状态间距 - 修复讲解页自动翻译缓存 key 不一致
1 parent 05db549 commit e178edb

10 files changed

Lines changed: 312 additions & 20 deletions

TASKS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@
6060

6161
## 最近完成(保留近两周)
6262

63+
- [x] 2026-07-13 19:56:修复讲解页自动翻译早于前后句上下文就绪导致写入无上下文缓存 key;自动翻译现在等待上下文稳定后再请求,避免返回页面缓存 miss。
64+
- [x] 2026-07-13 17:13:收紧随心听与学习页底部播放状态 label 到底部的间距,移动端压缩安全区占用,给上方内容更多空间。
65+
- [x] 2026-07-13 16:52:收紧句子讲解页原句与内联翻译之间的垂直间距,并补充组件回归测试。
6366
- [x] 2026-07-13 16:25:修复 CI 字典面板测试桩未覆盖增量 TTS 预热,避免落到真实控制器导致 `_coordinator` 未初始化。
6467
- [x] 2026-07-13 16:01:统一翻译加载态与解析加载态,翻译请求中按钮保留圆形进度,内容区改用单行 AI 骨架屏。
6568
- [x] 2026-07-13 15:42:修复意群手动点击超额后被提醒节流吞掉的问题,三类 AI 按钮手动超额均强制弹订阅提醒。

lib/screens/player_screen.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const kPlayerSingleSentenceSwipeAreaKey = ValueKey(
3434
const kPlayerBookmarkSingleSentenceSwipeAreaKey = ValueKey(
3535
'player-bookmark-single-sentence-swipe-area',
3636
);
37+
const kPlayerInfoBarStatusRowKey = ValueKey('player-info-bar-status-row');
3738

3839
class PlayerScreen extends ConsumerStatefulWidget {
3940
const PlayerScreen({super.key});
@@ -735,6 +736,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen>
735736
),
736737
),
737738
child: SafeArea(
739+
bottom: false,
738740
child: Column(
739741
mainAxisSize: MainAxisSize.min,
740742
children: [
@@ -822,6 +824,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen>
822824
final iconColor = mutedColor;
823825

824826
final statusRow = Row(
827+
key: kPlayerInfoBarStatusRowKey,
825828
mainAxisSize: MainAxisSize.min,
826829
children: [
827830
// 模式标签(列表/精听)依赖字幕分句,无字幕时隐藏。
@@ -878,10 +881,17 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen>
878881
],
879882
);
880883

884+
final mediaPadding = MediaQuery.paddingOf(context);
885+
final bottomPadding = centered
886+
? (mediaPadding.bottom * 0.35).clamp(4.0, AppSpacing.s).toDouble()
887+
: AppSpacing.s;
888+
881889
return Container(
882-
padding: const EdgeInsets.symmetric(
883-
horizontal: AppSpacing.m,
884-
vertical: AppSpacing.s,
890+
padding: EdgeInsets.fromLTRB(
891+
AppSpacing.m,
892+
AppSpacing.s,
893+
AppSpacing.m,
894+
bottomPadding,
885895
),
886896
child: centered
887897
? Center(child: statusRow)

lib/widgets/common/practice_playback_footer.dart

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import '../guide_flow.dart';
1111
import '../practice/practice_play_count_label.dart';
1212
import 'playback_controls.dart';
1313

14+
const kPracticePlaybackFooterLabelKey = ValueKey(
15+
'practice-playback-footer-label',
16+
);
17+
1418
/// 练习页面共享底部控制区
1519
class PracticePlaybackFooter extends StatelessWidget {
1620
/// 是否可以返回上一句
@@ -67,11 +71,19 @@ class PracticePlaybackFooter extends StatelessWidget {
6771

6872
@override
6973
Widget build(BuildContext context) {
74+
final mediaPadding = MediaQuery.paddingOf(context);
75+
final isMobile = MediaQuery.sizeOf(context).width < 600;
76+
final bottomPadding = isMobile
77+
? (mediaPadding.bottom * 0.35)
78+
.clamp(AppSpacing.xs, AppSpacing.s)
79+
.toDouble()
80+
: AppSpacing.m;
81+
7082
return Padding(
71-
padding: const EdgeInsets.only(
83+
padding: EdgeInsets.only(
7284
left: AppSpacing.l,
7385
right: AppSpacing.l,
74-
bottom: AppSpacing.m,
86+
bottom: bottomPadding,
7587
),
7688
child: Column(
7789
mainAxisSize: MainAxisSize.min,
@@ -87,6 +99,7 @@ class PracticePlaybackFooter extends StatelessWidget {
8799
),
88100
const SizedBox(height: AppSpacing.s),
89101
PracticePlayCountLabel(
102+
key: kPracticePlaybackFooterLabelKey,
90103
isManualMode: isManualMode,
91104
playCountText: playCountText,
92105
statusSuffixText: statusSuffixText,

lib/widgets/practice/annotation_content_view.dart

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -772,11 +772,33 @@ class _AnnotationContentViewState extends ConsumerState<AnnotationContentView> {
772772
.watch(learningSettingsProvider)
773773
.autoExpandCachedAnnotation;
774774
// watch 共享字幕投影:撑起其 autoDispose 生命周期(视图在屏则常驻)+ 同步取前后句。
775+
// 翻译缓存 key 包含前后句,必须等字幕投影就绪后才允许自动翻译;
776+
// 否则首帧会用 null/null 写入无上下文 key,返回页面时再用带上下文 key 读取就会 miss。
775777
final audioItemId = widget.audioItemId;
776-
final sentences = (audioItemId != null && audioItemId.isNotEmpty)
777-
? ref.watch(audioSentencesProvider(audioItemId)).valueOrNull
778+
final hasSentenceContextSource =
779+
audioItemId != null &&
780+
audioItemId.isNotEmpty &&
781+
widget.sentenceIndex != null;
782+
final sentencesAsync = (audioItemId != null && audioItemId.isNotEmpty)
783+
? ref.watch(audioSentencesProvider(audioItemId))
778784
: null;
779-
final (previousText, nextText) = _neighborTexts(sentences);
785+
final translationContextReady =
786+
!hasSentenceContextSource || (sentencesAsync?.hasValue ?? false);
787+
final sentences = translationContextReady
788+
? sentencesAsync?.valueOrNull
789+
: null;
790+
final (previousText, nextText) = translationContextReady
791+
? _neighborTexts(sentences)
792+
: (null, null);
793+
794+
Future<(String?, String?)> resolveTranslationContext() async {
795+
if (!hasSentenceContextSource) return (null, null);
796+
final loadedSentences =
797+
sentencesAsync?.valueOrNull ??
798+
await ref.read(audioSentencesProvider(audioItemId).future);
799+
return _neighborTexts(loadedSentences);
800+
}
801+
780802
final cachedTranslation = autoExpand
781803
? ai
782804
?.getCachedTranslation(
@@ -889,12 +911,14 @@ class _AnnotationContentViewState extends ConsumerState<AnnotationContentView> {
889911
? (cancelToken, source) async* {
890912
var hasContent = false;
891913
try {
914+
final (requestPreviousText, requestNextText) =
915+
await resolveTranslationContext();
892916
// await for(非 yield*)确保流内 auth/quota 错误在本
893917
// try 内重抛,从而弹登录/订阅(与 onRequestAnalysis 语义一致)。
894918
await for (final t in ai.getTranslationStream(
895919
widget.text,
896-
previous: previousText,
897-
next: nextText,
920+
previous: requestPreviousText,
921+
next: requestNextText,
898922
targetLanguage: nativeLanguage,
899923
accessToken: accessToken,
900924
cancelToken: cancelToken,
@@ -975,7 +999,8 @@ class _AnnotationContentViewState extends ConsumerState<AnnotationContentView> {
975999
: null,
9761000
cachedTranslation: cachedTranslation,
9771001
cachedAnalysis: cachedAnalysis,
978-
autoLoadTranslation: shouldAutoLoadSentenceAi,
1002+
autoLoadTranslation:
1003+
shouldAutoLoadSentenceAi && translationContextReady,
9791004
autoLoadAnalysis: shouldAutoLoadSentenceAi,
9801005
onTranslationUserIntent: () {
9811006
ref

lib/widgets/practice/practice_play_count_label.dart

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ library;
99
import 'package:flutter/material.dart';
1010

1111
import '../../l10n/app_localizations.dart';
12-
import '../../theme/app_theme.dart';
1312

1413
/// 格式化练习页的遍数文案。
1514
///
@@ -55,10 +54,7 @@ class PracticePlayCountLabel extends StatelessWidget {
5554

5655
@override
5756
Widget build(BuildContext context) {
58-
return Padding(
59-
padding: const EdgeInsets.only(bottom: AppSpacing.m),
60-
child: isManualMode ? _buildManualLabel() : _buildAutoLabel(),
61-
);
57+
return isManualMode ? _buildManualLabel() : _buildAutoLabel();
6258
}
6359

6460
/// 手动模式:高亮 "手动"

lib/widgets/practice/sentence_annotation_card.dart

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ enum ContentLoadState { idle, loading, loaded, error }
3232
/// 意群显示模式
3333
enum SenseGroupMode { off, medium, fine }
3434

35+
/// 句子顶部留白,给词组选区手柄圆点让位。
36+
const double _sentenceTopPadding = AppSpacing.m - 4;
37+
38+
/// 句子与内联翻译之间的留白,保持译文贴近原句。
39+
const double _sentenceBottomPadding = AppSpacing.xs;
40+
3541
/// 标注模式句子卡片
3642
///
3743
/// 句子文本经 [SelectableSentenceText] 渲染(点词查词 + 词组选区手柄),
@@ -909,12 +915,14 @@ class SentenceAnnotationCardState extends State<SentenceAnnotationCard> {
909915
crossAxisAlignment: CrossAxisAlignment.start,
910916
children: [
911917
// 句子文本 — 意群色块模式或纯 RichText(带长按/右键复制整句)。
912-
// 上下留白给选区手柄圆点(悬在首行上方/末行下方各约 12dp)让位,
913-
// 避免遮挡;上方留白由外部工具栏缩小间距补偿(annotation_content_view)。
918+
// 顶部留白给选区手柄圆点让位;底部收紧,让译文紧跟原句。
914919
_wrapGuide(
915920
widget.sentenceGuideStep,
916921
Padding(
917-
padding: const EdgeInsets.symmetric(vertical: AppSpacing.m - 4),
922+
padding: const EdgeInsets.only(
923+
top: _sentenceTopPadding,
924+
bottom: _sentenceBottomPadding,
925+
),
918926
child: sentenceBody,
919927
),
920928
),
@@ -953,7 +961,7 @@ class SentenceAnnotationCardState extends State<SentenceAnnotationCard> {
953961
);
954962
case ContentLoadState.loaded:
955963
content = Padding(
956-
padding: const EdgeInsets.only(top: AppSpacing.xs),
964+
padding: EdgeInsets.zero,
957965
child: Text(
958966
_translationContent ?? '',
959967
style: theme.textTheme.bodyMedium?.copyWith(

test/screens/player_screen_test.dart

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,44 @@ void main() {
329329
await _disposeTree(tester);
330330
});
331331

332+
testWidgets('移动端状态 label 不再占用完整底部安全区', (tester) async {
333+
tester.view.physicalSize = const Size(390, 844);
334+
tester.view.devicePixelRatio = 1.0;
335+
tester.view.padding = const FakeViewPadding(bottom: 34);
336+
addTearDown(tester.view.resetPhysicalSize);
337+
addTearDown(tester.view.resetDevicePixelRatio);
338+
addTearDown(tester.view.resetPadding);
339+
340+
final item = createTestAudioItem();
341+
final sentences = createTestSentences(count: 3);
342+
343+
await tester.pumpWidget(
344+
createTestScreen(
345+
const PlayerScreen(),
346+
locale: const Locale('zh'),
347+
overrides: _audioOverrides(
348+
practiceState: ListeningPracticeState(
349+
currentAudioItem: item,
350+
sentences: sentences,
351+
currentFullIndex: 0,
352+
),
353+
),
354+
),
355+
);
356+
await tester.pump();
357+
358+
final statusRowBottom = tester
359+
.getRect(find.byKey(kPlayerInfoBarStatusRowKey))
360+
.bottom;
361+
final screenBottom =
362+
tester.view.physicalSize.height / tester.view.devicePixelRatio;
363+
364+
// 底部状态行只保留压缩后的 8px 间距,避免 Home indicator 的 34px
365+
// 全量安全区把播放器控制面板顶高,给上方正文留出更多空间。
366+
expect(screenBottom - statusRowBottom, closeTo(8, 0.1));
367+
await _disposeTree(tester);
368+
});
369+
332370
testWidgets('恢复断点后进度条首帧显示当前绝对位置,不回到 0:00', (tester) async {
333371
final item = createTestAudioItem();
334372
final sentences = createTestSentences(count: 6);

0 commit comments

Comments
 (0)