mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
fix(lyrics): align pronunciation phrases and improve readability
This commit is contained in:
@@ -30,6 +30,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_output_button.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
@@ -2997,7 +2998,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
style: style,
|
||||
);
|
||||
painter.layout(maxWidth: width);
|
||||
final height = painter.height + 32;
|
||||
var height = painter.height + 32;
|
||||
var pronunciationHeight = 0.0;
|
||||
var translationHeight = 0.0;
|
||||
for (final (text, style, _, translation) in _lyricSupplements(
|
||||
@@ -3010,6 +3011,19 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
translationHeight = 6 + painter.height;
|
||||
} else {
|
||||
pronunciationHeight = 6 + painter.height;
|
||||
final aligned = LyricPronunciationLayout.measure(
|
||||
line: line,
|
||||
primaryStyle: _mornyeLyricStyle(context),
|
||||
pronunciationStyle: style,
|
||||
maxWidth: width,
|
||||
textScaler: scaler,
|
||||
textDirection: direction,
|
||||
locale: locale,
|
||||
);
|
||||
if (aligned != null) {
|
||||
height = aligned.primaryHeight + 32;
|
||||
pronunciationHeight = aligned.pronunciationHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
measurements.add((height, pronunciationHeight, translationHeight));
|
||||
@@ -3358,10 +3372,12 @@ Iterable<(String, TextStyle, List<LyricWord>, bool)> _lyricSupplements(
|
||||
text,
|
||||
base.copyWith(
|
||||
fontSize: context.isMornye
|
||||
? (translation ? 15 : 17)
|
||||
? (translation ? 18 : 22)
|
||||
: (translation ? 14 : 16),
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: context.isMornye
|
||||
? (translation ? FontWeight.w600 : FontWeight.bold)
|
||||
: FontWeight.w500,
|
||||
),
|
||||
translation ? const <LyricWord>[] : line.romanizationWords,
|
||||
translation,
|
||||
@@ -3369,6 +3385,13 @@ Iterable<(String, TextStyle, List<LyricWord>, bool)> _lyricSupplements(
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle _mornyeLyricStyle(BuildContext context) =>
|
||||
(Theme.of(context).textTheme.headlineSmall ?? const TextStyle()).copyWith(
|
||||
fontSize: _mornyeLyricFontSize,
|
||||
height: context.tokens.lyricsLineHeight,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
|
||||
Widget _withLyricSupplements(
|
||||
BuildContext context,
|
||||
LyricLine line,
|
||||
@@ -3378,17 +3401,16 @@ Widget _withLyricSupplements(
|
||||
Widget Function(String, List<LyricWord>, TextStyle)? timedText,
|
||||
}) {
|
||||
if (line.romanization == null && line.translation == null) return primary;
|
||||
return Column(
|
||||
final supplements = _lyricSupplements(context, line).toList();
|
||||
Widget withSupplements(Widget primary, {bool aligned = false}) => Column(
|
||||
crossAxisAlignment: context.isMornye
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
primary,
|
||||
for (final (text, style, words, translation) in _lyricSupplements(
|
||||
context,
|
||||
line,
|
||||
))
|
||||
if ((translation ? visibility.dy : visibility.dx) > 0)
|
||||
for (final (text, style, words, translation) in supplements)
|
||||
if ((translation || !aligned) &&
|
||||
(translation ? visibility.dy : visibility.dx) > 0)
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: context.isMornye
|
||||
@@ -3416,6 +3438,42 @@ Widget _withLyricSupplements(
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!context.isMornye ||
|
||||
line.romanizationWords.isEmpty ||
|
||||
line.romanization?.trim().isNotEmpty != true) {
|
||||
return withSupplements(primary);
|
||||
}
|
||||
final primaryStyle = _mornyeLyricStyle(context);
|
||||
final pronunciationStyle = supplements
|
||||
.firstWhere((supplement) => !supplement.$4)
|
||||
.$2;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final layout = LyricPronunciationLayout.measure(
|
||||
line: line,
|
||||
primaryStyle: primaryStyle,
|
||||
pronunciationStyle: pronunciationStyle,
|
||||
maxWidth: constraints.maxWidth,
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
textDirection: Directionality.of(context),
|
||||
locale: Localizations.maybeLocaleOf(context),
|
||||
);
|
||||
if (layout == null) return withSupplements(primary);
|
||||
return withSupplements(
|
||||
AlignedLyricPronunciation(
|
||||
layout: layout,
|
||||
visibility: visibility.dx,
|
||||
primaryStyle: primaryStyle,
|
||||
pronunciationStyle: pronunciationStyle,
|
||||
textBuilder:
|
||||
timedText ??
|
||||
(text, words, style) =>
|
||||
Text(text, style: style.copyWith(color: color)),
|
||||
),
|
||||
aligned: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
|
||||
/// Keeps each pronunciation phrase under the lyric with the same start time.
|
||||
/// Only shared word boundaries are used; line-only timing cannot identify
|
||||
/// which original characters a pronunciation belongs to.
|
||||
class LyricPronunciationLayout {
|
||||
final List<_PronunciationRow> _rows;
|
||||
|
||||
const LyricPronunciationLayout._(this._rows);
|
||||
|
||||
static const _phraseGap = 4.0;
|
||||
static const _pronunciationGap = 2.0;
|
||||
static const _rowGap = 6.0;
|
||||
|
||||
double get primaryHeight =>
|
||||
_rows.fold(0.0, (height, row) => height + row.primaryHeight) +
|
||||
(_rows.length - 1) * _rowGap;
|
||||
|
||||
double get pronunciationHeight => _rows.fold(
|
||||
0.0,
|
||||
(height, row) => height + _pronunciationGap + row.pronunciationHeight,
|
||||
);
|
||||
|
||||
static LyricPronunciationLayout? measure({
|
||||
required LyricLine line,
|
||||
required TextStyle primaryStyle,
|
||||
required TextStyle pronunciationStyle,
|
||||
required double maxWidth,
|
||||
required TextScaler textScaler,
|
||||
required TextDirection textDirection,
|
||||
Locale? locale,
|
||||
}) {
|
||||
final original = line.words;
|
||||
final pronunciation = line.romanizationWords;
|
||||
if (original.isEmpty ||
|
||||
pronunciation.isEmpty ||
|
||||
!maxWidth.isFinite ||
|
||||
maxWidth <= 0) {
|
||||
return null;
|
||||
}
|
||||
String normalize(String text) => text.replaceAll(RegExp(r'\s+'), '');
|
||||
if (normalize(original.map((word) => word.text).join()) !=
|
||||
normalize(line.text) ||
|
||||
normalize(pronunciation.map((word) => word.text).join()) !=
|
||||
normalize(line.romanization ?? '') ||
|
||||
(original.first.time - pronunciation.first.time).inMilliseconds.abs() >
|
||||
10) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final boundaries = <(int, int)>[(0, 0)];
|
||||
var originalIndex = 1;
|
||||
for (var i = 1; i < pronunciation.length; i++) {
|
||||
// Preserve syllables inside a word. Providers may split a romanized
|
||||
// word into several timed spans without adding whitespace.
|
||||
if (!RegExp(r'\s$').hasMatch(pronunciation[i - 1].text) &&
|
||||
!RegExp(r'^\s').hasMatch(pronunciation[i].text)) {
|
||||
continue;
|
||||
}
|
||||
final start = pronunciation[i].time.inMilliseconds;
|
||||
while (originalIndex < original.length &&
|
||||
original[originalIndex].time.inMilliseconds < start - 10) {
|
||||
originalIndex++;
|
||||
}
|
||||
if (originalIndex < original.length &&
|
||||
(original[originalIndex].time.inMilliseconds - start).abs() <= 10) {
|
||||
boundaries.add((originalIndex, i));
|
||||
originalIndex++;
|
||||
}
|
||||
}
|
||||
if (boundaries.length < 2) return null;
|
||||
boundaries.add((original.length, pronunciation.length));
|
||||
|
||||
final painter = TextPainter(
|
||||
textDirection: textDirection,
|
||||
textScaler: textScaler,
|
||||
locale: locale,
|
||||
);
|
||||
Size measureText(String text, TextStyle style) {
|
||||
painter.text = TextSpan(text: text, style: style);
|
||||
painter.layout(maxWidth: maxWidth);
|
||||
return Size(painter.width.ceilToDouble(), painter.height);
|
||||
}
|
||||
|
||||
final rows = <_PronunciationRow>[];
|
||||
var groups = <_PronunciationGroup>[];
|
||||
var rowWidth = 0.0;
|
||||
var primaryHeight = 0.0;
|
||||
var pronunciationHeight = 0.0;
|
||||
for (var i = 0; i < boundaries.length - 1; i++) {
|
||||
final start = boundaries[i];
|
||||
final end = boundaries[i + 1];
|
||||
final primaryWords = _sliceWords(original, start.$1, end.$1);
|
||||
final pronunciationWords = _sliceWords(pronunciation, start.$2, end.$2);
|
||||
final primarySize = measureText(
|
||||
primaryWords.map((word) => word.text).join(),
|
||||
primaryStyle,
|
||||
);
|
||||
final pronunciationSize = measureText(
|
||||
pronunciationWords.map((word) => word.text).join(),
|
||||
pronunciationStyle,
|
||||
);
|
||||
final width = math.min(
|
||||
maxWidth,
|
||||
math.max(primarySize.width, pronunciationSize.width),
|
||||
);
|
||||
if (groups.isNotEmpty && rowWidth + _phraseGap + width > maxWidth) {
|
||||
rows.add(_PronunciationRow(groups, primaryHeight, pronunciationHeight));
|
||||
groups = [];
|
||||
rowWidth = primaryHeight = pronunciationHeight = 0;
|
||||
}
|
||||
if (groups.isNotEmpty) rowWidth += _phraseGap;
|
||||
groups.add(_PronunciationGroup(primaryWords, pronunciationWords, width));
|
||||
rowWidth += width;
|
||||
primaryHeight = math.max(primaryHeight, primarySize.height);
|
||||
pronunciationHeight = math.max(
|
||||
pronunciationHeight,
|
||||
pronunciationSize.height,
|
||||
);
|
||||
}
|
||||
rows.add(_PronunciationRow(groups, primaryHeight, pronunciationHeight));
|
||||
painter.dispose();
|
||||
return LyricPronunciationLayout._(rows);
|
||||
}
|
||||
|
||||
static List<LyricWord> _sliceWords(
|
||||
List<LyricWord> words,
|
||||
int start,
|
||||
int end,
|
||||
) => [
|
||||
for (var i = start; i < end; i++)
|
||||
LyricWord(
|
||||
time: words[i].time,
|
||||
end: words[i].end ?? words.elementAtOrNull(i + 1)?.time,
|
||||
text: i == start && i == end - 1
|
||||
? words[i].text.trim()
|
||||
: i == start
|
||||
? words[i].text.trimLeft()
|
||||
: i == end - 1
|
||||
? words[i].text.trimRight()
|
||||
: words[i].text,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
class _PronunciationGroup {
|
||||
final List<LyricWord> original;
|
||||
final List<LyricWord> pronunciation;
|
||||
final double width;
|
||||
|
||||
const _PronunciationGroup(this.original, this.pronunciation, this.width);
|
||||
}
|
||||
|
||||
class _PronunciationRow {
|
||||
final List<_PronunciationGroup> groups;
|
||||
final double primaryHeight;
|
||||
final double pronunciationHeight;
|
||||
|
||||
const _PronunciationRow(
|
||||
this.groups,
|
||||
this.primaryHeight,
|
||||
this.pronunciationHeight,
|
||||
);
|
||||
}
|
||||
|
||||
class AlignedLyricPronunciation extends StatelessWidget {
|
||||
final LyricPronunciationLayout layout;
|
||||
final double visibility;
|
||||
final TextStyle primaryStyle;
|
||||
final TextStyle pronunciationStyle;
|
||||
final Widget Function(String, List<LyricWord>, TextStyle) textBuilder;
|
||||
|
||||
const AlignedLyricPronunciation({
|
||||
super.key,
|
||||
required this.layout,
|
||||
required this.visibility,
|
||||
required this.primaryStyle,
|
||||
required this.pronunciationStyle,
|
||||
required this.textBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < layout._rows.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: LyricPronunciationLayout._rowGap),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var j = 0; j < layout._rows[i].groups.length; j++) ...[
|
||||
if (j > 0)
|
||||
const SizedBox(width: LyricPronunciationLayout._phraseGap),
|
||||
_buildGroup(layout._rows[i].groups[j], layout._rows[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Widget _buildGroup(_PronunciationGroup group, _PronunciationRow row) =>
|
||||
SizedBox(
|
||||
width: group.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: row.primaryHeight,
|
||||
child: textBuilder(
|
||||
group.original.map((word) => word.text).join(),
|
||||
group.original,
|
||||
primaryStyle,
|
||||
),
|
||||
),
|
||||
if (visibility > 0)
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
heightFactor: visibility,
|
||||
child: Opacity(
|
||||
opacity: visibility,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: LyricPronunciationLayout._pronunciationGap,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: row.pronunciationHeight,
|
||||
child: textBuilder(
|
||||
group.pronunciation.map((word) => word.text).join(),
|
||||
group.pronunciation,
|
||||
pronunciationStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart';
|
||||
|
||||
const _primaryStyle = TextStyle(fontSize: 34, height: 1.3);
|
||||
const _pronunciationStyle = TextStyle(fontSize: 22, height: 1.35);
|
||||
|
||||
LyricWord _word(String text, int start, [int? end]) => LyricWord(
|
||||
text: text,
|
||||
time: Duration(milliseconds: start),
|
||||
end: end == null ? null : Duration(milliseconds: end),
|
||||
);
|
||||
|
||||
LyricLine _line({int pronunciationOffset = 0}) => LyricLine(
|
||||
time: Duration.zero,
|
||||
text: '今日も歌う',
|
||||
words: [
|
||||
_word('今', 0),
|
||||
_word('日', 200),
|
||||
_word('も', 500),
|
||||
_word('歌う', 800, 1500),
|
||||
],
|
||||
romanization: 'kyou mo utau',
|
||||
romanizationWords: [
|
||||
_word('kyo', pronunciationOffset, 200),
|
||||
_word('u ', 200, 400),
|
||||
_word('mo ', 500, 750),
|
||||
_word('utau', 800, 1500),
|
||||
],
|
||||
);
|
||||
|
||||
LyricPronunciationLayout? _layout(
|
||||
LyricLine line, {
|
||||
double width = 320,
|
||||
double scale = 1,
|
||||
}) => LyricPronunciationLayout.measure(
|
||||
line: line,
|
||||
primaryStyle: _primaryStyle,
|
||||
pronunciationStyle: _pronunciationStyle,
|
||||
maxWidth: width,
|
||||
textScaler: TextScaler.linear(scale),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('does not guess alignment without matching word timing', () {
|
||||
expect(_layout(_line(pronunciationOffset: 100)), isNull);
|
||||
expect(
|
||||
_layout(
|
||||
const LyricLine(
|
||||
time: Duration.zero,
|
||||
text: 'Original',
|
||||
romanization: 'Pronunciation',
|
||||
),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
for (final width in [320.0, 170.0]) {
|
||||
for (final scale in [1.0, 1.8]) {
|
||||
testWidgets(
|
||||
'phrases stay paired when wrapping and hiding ($width, $scale)',
|
||||
(tester) async {
|
||||
final layout = _layout(_line(), width: width, scale: scale)!;
|
||||
final renderedWords = <String, List<LyricWord>>{};
|
||||
Future<void> pump(double visibility) => tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(textScaler: TextScaler.linear(scale)),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: AlignedLyricPronunciation(
|
||||
layout: layout,
|
||||
visibility: visibility,
|
||||
primaryStyle: _primaryStyle,
|
||||
pronunciationStyle: _pronunciationStyle,
|
||||
textBuilder: (text, words, style) {
|
||||
renderedWords[text] = words;
|
||||
return Text(text, style: style);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await pump(1);
|
||||
final block = find.byType(AlignedLyricPronunciation);
|
||||
final bounds = tester.getRect(block);
|
||||
expect(
|
||||
bounds.height,
|
||||
closeTo(layout.primaryHeight + layout.pronunciationHeight, 0.01),
|
||||
);
|
||||
final primaryPositions = <Offset>[];
|
||||
for (final (original, pronunciation) in [
|
||||
('今日', 'kyou'),
|
||||
('も', 'mo'),
|
||||
('歌う', 'utau'),
|
||||
]) {
|
||||
final primary = tester.getRect(find.text(original));
|
||||
final secondary = tester.getRect(find.text(pronunciation));
|
||||
primaryPositions.add(primary.topLeft);
|
||||
expect(primary.left, closeTo(secondary.left, 0.01));
|
||||
expect(secondary.top, greaterThanOrEqualTo(primary.bottom));
|
||||
expect(secondary.right, lessThanOrEqualTo(bounds.right + 0.01));
|
||||
}
|
||||
// Syllables stay together, and the end of a sliced phrase still
|
||||
// points to the next original word, not the end of the whole line.
|
||||
expect(renderedWords['kyou']!.map((word) => word.text), ['kyo', 'u']);
|
||||
expect(renderedWords['今日']!.last.end!.inMilliseconds, 500);
|
||||
expect(renderedWords['kyou']!.last.end!.inMilliseconds, 400);
|
||||
expect(renderedWords['utau']!.single.end!.inMilliseconds, 1500);
|
||||
final firstPosition = primaryPositions.first;
|
||||
for (final visibility in [0.5, 0.0]) {
|
||||
await pump(visibility);
|
||||
expect(
|
||||
tester.getSize(block).height,
|
||||
closeTo(
|
||||
layout.primaryHeight + layout.pronunciationHeight * visibility,
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
expect(tester.getTopLeft(find.text('今日')), firstPosition);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('も')).dx,
|
||||
primaryPositions[1].dx,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
}
|
||||
expect(find.text('kyou'), findsNothing);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2025,6 +2025,13 @@ void main() {
|
||||
for (final text in ['Original text', 'Romanized text', 'English text']) {
|
||||
expect(find.text(text), findsOneWidget);
|
||||
}
|
||||
if (mornye) {
|
||||
final pronunciation = tester.widget<Text>(find.text('Romanized text'));
|
||||
final translation = tester.widget<Text>(find.text('English text'));
|
||||
expect(pronunciation.style?.fontSize, 22);
|
||||
expect(pronunciation.style?.fontWeight, FontWeight.bold);
|
||||
expect(translation.style?.fontSize, 18);
|
||||
}
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Romanized text')).dy,
|
||||
greaterThan(tester.getBottomLeft(find.text('Original text')).dy),
|
||||
@@ -2166,32 +2173,42 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final paint = find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.label == text,
|
||||
),
|
||||
matching: find.byType(CustomPaint),
|
||||
);
|
||||
expect(paint, findsOneWidget, reason: '$text at $milliseconds ms');
|
||||
final painter = tester.widget<CustomPaint>(paint).painter!;
|
||||
final size = tester.getSize(paint);
|
||||
return (await tester.runAsync(() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
painter.paint(Canvas(recorder), size);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(
|
||||
size.width.ceil(),
|
||||
size.height.ceil(),
|
||||
final pixels = <int>[];
|
||||
for (final phrase in mornye ? text.split(' ') : [text]) {
|
||||
final paint = find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.label == phrase,
|
||||
),
|
||||
matching: find.byType(CustomPaint),
|
||||
);
|
||||
final bytes = (await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
))!;
|
||||
final pixels = bytes.buffer.asUint8List().toList();
|
||||
image.dispose();
|
||||
picture.dispose();
|
||||
return pixels;
|
||||
}))!;
|
||||
expect(
|
||||
paint,
|
||||
findsOneWidget,
|
||||
reason: '$phrase at $milliseconds ms',
|
||||
);
|
||||
final painter = tester.widget<CustomPaint>(paint).painter!;
|
||||
final size = tester.getSize(paint);
|
||||
pixels.addAll(
|
||||
(await tester.runAsync(() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
painter.paint(Canvas(recorder), size);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(
|
||||
size.width.ceil(),
|
||||
size.height.ceil(),
|
||||
);
|
||||
final bytes = (await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
))!;
|
||||
final result = bytes.buffer.asUint8List().toList();
|
||||
image.dispose();
|
||||
picture.dispose();
|
||||
return result;
|
||||
}))!,
|
||||
);
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
for (final text in ['First second', 'Firsu secondu']) {
|
||||
|
||||
Reference in New Issue
Block a user