feat(lyrics): share lyric cards and refine timed vocal transitions

This commit is contained in:
zarzet committed 2026-10-17 16:18:35 +07:00
1 parent 40f65d3a4d
commit 78a5e078cd
12 files changed
+1213 -48

No files matched your search

+683
View File
@@ -0,0 +1,683 @@
import 'dart:io';
import 'dart:ui' show ImageByteFormat, ImageFilter;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile;
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_icon_button.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
/// Story-sized lyric cards stay short, like quoting a song rather than
/// reproducing its lyrics.
const lyricsShareMaxLines = 5;
/// The logical size of the shared card; captured at 3× for 1080×1920.
const lyricsShareCardSize = Size(360, 640);
/// One selectable lyric line.
typedef LyricsShareLine = ({String text, bool background});
/// Lines a card can quote: timed lines in order, or the plain text's lines.
List<LyricsShareLine> lyricsShareLines(ParsedLyrics lyrics) {
if (lyrics.lines.isNotEmpty) {
return [
for (final line in lyrics.lines)
if (line.text.trim().isNotEmpty)
(text: line.text.trim(), background: line.isBackground),
];
}
return [
for (final line in lyrics.plainText.split(RegExp(r'\r\n|\r|\n')))
if (line.trim().isNotEmpty) (text: line.trim(), background: false),
];
}
/// Opens the lyric card editor. [initial] preselects the line the user
/// long-pressed in the player.
Future<void> openLyricsShare(
BuildContext context, {
required MediaItem mediaItem,
required ParsedLyrics lyrics,
LyricLine? initial,
}) {
final lines = lyricsShareLines(lyrics);
var selected = -1;
if (initial != null) {
var row = 0;
for (final line in lyrics.lines) {
if (line.text.trim().isEmpty) continue;
if (line.time == initial.time &&
line.text.trim() == initial.text.trim()) {
selected = row;
break;
}
row++;
}
}
return Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => LyricsShareScreen(
mediaItem: mediaItem,
lines: lines,
initialSelection: {if (selected >= 0) selected},
),
),
);
}
enum LyricsCardBackground { artwork, color, dark, light }
class LyricsShareScreen extends StatefulWidget {
const LyricsShareScreen({
super.key,
required this.mediaItem,
required this.lines,
this.initialSelection = const {},
});
final MediaItem mediaItem;
final List<LyricsShareLine> lines;
final Set<int> initialSelection;
@override
State<LyricsShareScreen> createState() => _LyricsShareScreenState();
}
class _LyricsShareScreenState extends State<LyricsShareScreen> {
final _cardKey = GlobalKey();
final _shareButtonKey = GlobalKey();
late final Set<int> _selected = {...widget.initialSelection};
late final ScrollController _list = ScrollController(
initialScrollOffset: widget.initialSelection.isEmpty
? 0
: (widget.initialSelection.first - 2).clamp(0, 1 << 20) * 52.0,
);
LyricsCardBackground _background = LyricsCardBackground.artwork;
bool _sharing = false;
String? get _artSource {
final art = widget.mediaItem.artUri;
if (art == null) return null;
return art.scheme == 'file' ? art.toFilePath() : art.toString();
}
@override
void dispose() {
_list.dispose();
super.dispose();
}
void _toggle(int index) {
setState(() {
if (_selected.remove(index)) return;
if (_selected.length >= lyricsShareMaxLines) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(context.l10n.lyricsShareLimit(lyricsShareMaxLines)),
),
);
return;
}
_selected.add(index);
});
}
List<LyricsShareLine> get _quoted => [
for (final index in _selected.toList()..sort()) widget.lines[index],
];
Future<void> _share() async {
if (_sharing || _selected.isEmpty) return;
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final origin = _originOf(_shareButtonKey.currentContext);
setState(() => _sharing = true);
try {
// A selection/background change can still be waiting for paint when
// Share is tapped. Capture the current card after that frame finishes.
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
final boundary =
_cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) throw StateError('Card is not laid out');
final image = await boundary.toImage(pixelRatio: 3);
final bytes = await image.toByteData(format: ImageByteFormat.png);
image.dispose();
if (bytes == null) throw StateError('Card could not be encoded');
final directory = await getTemporaryDirectory();
final file = File('${directory.path}/spotiflac_lyrics.png');
await file.writeAsBytes(bytes.buffer.asUint8List(), flush: true);
await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: 'image/png')],
text: [
widget.mediaItem.title,
if ((widget.mediaItem.artist ?? '').trim().isNotEmpty)
widget.mediaItem.artist!.trim(),
].join(' — '),
sharePositionOrigin: origin,
),
);
} catch (_) {
messenger.showSnackBar(SnackBar(content: Text(l10n.lyricsShareError)));
} finally {
if (mounted) setState(() => _sharing = false);
}
}
static Rect? _originOf(BuildContext? context) {
final box = context?.findRenderObject();
if (box is! RenderBox || !box.hasSize) return null;
return box.localToGlobal(Offset.zero) & box.size;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final tokens = context.tokens;
final l10n = context.l10n;
final source = _artSource;
return Scaffold(
appBar: AppBar(
leading: AppIconButton(
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
icon: const AppIcon(Icons.close),
onPressed: () => Navigator.of(context).maybePop(),
),
title: Text(l10n.lyricsShareTitle),
),
body: SafeArea(
top: false,
child: Column(
children: [
Expanded(
flex: 6,
child: Padding(
padding: EdgeInsets.symmetric(vertical: tokens.gapSm),
child: Center(
child: AspectRatio(
aspectRatio:
lyricsShareCardSize.width / lyricsShareCardSize.height,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: FittedBox(
child: CoverPaletteBuilder(
imageSource: source,
builder: (context, _) => RepaintBoundary(
key: _cardKey,
child: LyricsShareCard(
title: widget.mediaItem.title,
artist: widget.mediaItem.artist ?? '',
artUri: widget.mediaItem.artUri?.toString(),
lines: _quoted,
background: _background,
coverColor: source == null
? null
: CoverPalette.sourceColor(
source,
Theme.of(context).brightness,
),
placeholder: l10n.lyricsShareSelectHint(
lyricsShareMaxLines,
),
),
),
),
),
),
),
),
),
),
_BackgroundPicker(
value: _background,
onChanged: (value) => setState(() => _background = value),
),
Padding(
padding: EdgeInsets.fromLTRB(
tokens.gapXl,
tokens.gapSm,
tokens.gapXl,
tokens.gapXs,
),
child: Text(
l10n.lyricsShareSelectHint(lyricsShareMaxLines),
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
Expanded(
flex: 4,
child: ListView.builder(
controller: _list,
padding: EdgeInsets.symmetric(horizontal: tokens.gapMd),
itemCount: widget.lines.length,
itemBuilder: (context, index) => _LineChoice(
line: widget.lines[index],
selected: _selected.contains(index),
onTap: () => _toggle(index),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
tokens.gapLg,
tokens.gapSm,
tokens.gapLg,
tokens.gapLg,
),
child: SizedBox(
width: double.infinity,
child: ExpressiveButton(
key: _shareButtonKey,
icon: const AppIcon(Icons.ios_share),
onPressed: _selected.isEmpty || _sharing ? null : _share,
child: Text(l10n.lyricsShareAction),
),
),
),
],
),
),
);
}
}
class _BackgroundPicker extends StatelessWidget {
const _BackgroundPicker({required this.value, required this.onChanged});
final LyricsCardBackground value;
final ValueChanged<LyricsCardBackground> onChanged;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final colors = Theme.of(context).colorScheme;
final labels = {
LyricsCardBackground.artwork: l10n.lyricsShareBackgroundArtwork,
LyricsCardBackground.color: l10n.lyricsShareBackgroundColor,
LyricsCardBackground.dark: l10n.lyricsShareBackgroundDark,
LyricsCardBackground.light: l10n.lyricsShareBackgroundLight,
};
final swatches = {
LyricsCardBackground.artwork: const LinearGradient(
colors: [Color(0xFF8E6BBF), Color(0xFF2C5E8C)],
),
LyricsCardBackground.color: LinearGradient(
colors: [colors.primary, colors.primaryContainer],
),
LyricsCardBackground.dark: const LinearGradient(
colors: [Color(0xFF2A2A2A), Color(0xFF0E0E0E)],
),
LyricsCardBackground.light: const LinearGradient(
colors: [Color(0xFFFFFFFF), Color(0xFFE8E4DC)],
),
};
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final option in LyricsCardBackground.values)
Semantics(
button: true,
selected: option == value,
inMutuallyExclusiveGroup: true,
label: labels[option],
child: Tooltip(
message: labels[option],
excludeFromSemantics: true,
child: InkResponse(
onTap: () => onChanged(option),
radius: 26,
child: SizedBox.square(
dimension: 52,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: swatches[option],
border: Border.all(
color: option == value
? colors.primary
: colors.outlineVariant,
width: option == value ? 3 : 1,
),
),
child: option == LyricsCardBackground.artwork
? const AppIcon(
Icons.image_outlined,
size: 16,
color: Colors.white,
)
: null,
),
),
),
),
),
),
],
);
}
}
class _LineChoice extends StatelessWidget {
const _LineChoice({
required this.line,
required this.selected,
required this.onTap,
});
final LyricsShareLine line;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return MergeSemantics(
child: Semantics(
checked: selected,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 52),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
AppIcon(
selected ? Icons.check_circle : Icons.circle_outlined,
size: 22,
color: selected ? colors.primary : colors.outline,
),
const SizedBox(width: 12),
Expanded(
child: Text(
line.text,
style:
(line.background
? theme.textTheme.bodyMedium
: theme.textTheme.bodyLarge)
?.copyWith(
color: selected
? colors.onSurface
: colors.onSurfaceVariant,
fontWeight: selected ? FontWeight.w600 : null,
),
),
),
],
),
),
),
),
),
);
}
}
/// The shared image: quoted lines over a background derived from the cover.
/// It has a fixed logical size so the export never depends on the device.
class LyricsShareCard extends StatelessWidget {
const LyricsShareCard({
super.key,
required this.title,
required this.artist,
required this.artUri,
required this.lines,
required this.background,
this.coverColor,
this.placeholder,
});
final String title;
final String artist;
final String? artUri;
final List<LyricsShareLine> lines;
final LyricsCardBackground background;
/// Average cover color for [LyricsCardBackground.color].
final Color? coverColor;
/// Shown faintly in the preview while no line is selected.
final String? placeholder;
/// Larger type for short quotes, smaller for long ones, so every selection
/// fills the card comfortably.
static double fontSizeFor(List<LyricsShareLine> lines) {
final length = lines.fold<int>(0, (sum, line) => sum + line.text.length);
if (length <= 40) return 36;
if (length <= 90) return 31;
if (length <= 160) return 26;
return 22;
}
@override
Widget build(BuildContext context) {
final light = background == LyricsCardBackground.light;
final foreground = light ? const Color(0xFF111111) : Colors.white;
final secondary = foreground.withValues(alpha: 0.7);
final fontSize = fontSizeFor(lines);
final scheme = Theme.of(context).colorScheme;
return MediaQuery.withNoTextScaling(
child: SizedBox.fromSize(
size: lyricsShareCardSize,
child: Stack(
fit: StackFit.expand,
children: [
_background(scheme),
Padding(
padding: const EdgeInsets.fromLTRB(28, 40, 28, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(),
Container(
width: double.infinity,
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: foreground.withValues(alpha: light ? 0.06 : 0.12),
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox.square(
dimension: 44,
child: PlayerArtwork(
artUri: artUri,
colorScheme: scheme,
iconSize: 22,
cacheWidth: 132,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
if (artist.isNotEmpty)
Text(
artist,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: secondary,
fontSize: 13,
),
),
],
),
),
],
),
const SizedBox(height: 22),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 380),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: lyricsShareCardSize.width - 100,
child: lines.isEmpty
? Text(
placeholder ?? '',
style: TextStyle(
color: foreground.withValues(
alpha: 0.4,
),
fontSize: 20,
fontWeight: FontWeight.w600,
),
)
: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
for (final line in lines)
Padding(
padding: const EdgeInsets.only(
bottom: 6,
),
child: Text(
line.text,
style: TextStyle(
color: line.background
? secondary
: foreground,
fontSize: line.background
? fontSize * 0.72
: fontSize,
height: 1.18,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
),
],
),
),
),
),
],
),
),
const Spacer(),
Center(
child: Text(
'SpotiFLAC',
style: TextStyle(
color: secondary,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
),
],
),
),
],
),
),
);
}
Widget _background(ColorScheme scheme) {
switch (background) {
case LyricsCardBackground.artwork:
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Color(0xFF1B1B1F)),
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 36,
sigmaY: 36,
tileMode: TileMode.mirror,
),
child: Transform.scale(
scale: 1.3,
child: PlayerArtwork(
artUri: artUri,
colorScheme: scheme,
cacheWidth: 360,
),
),
),
const ColoredBox(color: Color(0x47000000)),
],
);
case LyricsCardBackground.color:
final hsl = HSLColor.fromColor(coverColor ?? scheme.primary);
final base = hsl.withSaturation(hsl.saturation.clamp(0.25, 0.6));
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
base.withLightness(0.44).toColor(),
base.withLightness(0.2).toColor(),
],
),
),
);
case LyricsCardBackground.dark:
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF26262B), Color(0xFF0B0B0D)],
),
),
);
case LyricsCardBackground.light:
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFFFFF), Color(0xFFEDE9E1)],
),
),
);
}
}
}
+25
View File
@@ -16,9 +16,11 @@ import 'package:spotiflac_android/routes/now_playing_route.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/screens/equalizer_screen.dart';
import 'package:spotiflac_android/screens/lyrics_share_screen.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
@@ -664,8 +666,20 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
options: showOptions && isActive && !_landscape
? _autoHidingLyricsControls(_lyricsOptionsButton(colorScheme))
: null,
onShareLyrics: _shareLyrics,
);
Future<void> _shareLyrics([LyricLine? line]) async {
final mediaItem = ref.read(currentMediaItemProvider).value;
if (mediaItem == null || _trackMetadata.lyrics.isEmpty) return;
await openLyricsShare(
context,
mediaItem: mediaItem,
lyrics: _trackMetadata.lyrics,
initial: line,
);
}
Widget _lyricsOptionsButton(ColorScheme colorScheme) => ListenableBuilder(
listenable: _trackMetadata,
builder: (context, _) =>
@@ -896,6 +910,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
builder: (_) => MornyePlayerActionsSheet(
mediaItem: mediaItem,
sleepTimerSubtitle: sleepTimerSubtitle,
canShareLyrics: !_trackMetadata.lyrics.isEmpty,
),
)
: await showAppBottomSheet<String>(
@@ -919,6 +934,13 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
title: sheetContext.l10n.nowPlayingDetails,
onTap: () => Navigator.of(sheetContext).pop('details'),
),
if (!_trackMetadata.lyrics.isEmpty)
SettingsItem(
icon: Icons.format_quote_outlined,
title: sheetContext.l10n.lyricsShareTitle,
onTap: () =>
Navigator.of(sheetContext).pop('shareLyrics'),
),
SettingsItem(
icon: Icons.bedtime_outlined,
title: sheetContext.l10n.nowPlayingSleepTimer,
@@ -975,6 +997,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
case 'album':
await _goToCurrentAlbum(mediaItem: mediaItem, source: source);
break;
case 'shareLyrics':
await _shareLyrics();
break;
case 'details':
unawaited(
showPlayerDetailsSheet(
+93
View File
@@ -133,3 +133,96 @@ List<LyricLine> lyricsTimelineWithGaps(List<LyricLine> lines) {
// intentionally omitted, and credits stay after the last sung line.
return List.unmodifiable(timeline);
}
/// Gives every word-timed line a known end for the player. Enhanced LRC often
/// omits the closing timestamp after the final word, which used to stop its
/// sweep after a fixed moment while the word was still being held.
///
/// A following line within [nearGap] receives the remaining time, so the last
/// word keeps sweeping slowly into it. A distant line usually follows a break:
/// the word then ends after a duration derived from the line's own pace.
/// Apply after [lyricsTimelineWithGaps]; countdown rows never use estimates.
List<LyricLine> lyricsWithDisplayEnds(
List<LyricLine> timeline, {
Duration nearGap = const Duration(seconds: 4),
}) {
final result = <LyricLine>[];
for (var i = 0; i < timeline.length; i++) {
final line = timeline[i];
final last = line.words.lastOrNull;
if (line.text.isEmpty || last == null) {
result.add(line);
continue;
}
var end = line.end;
if (last.end != null && (end == null || last.end! > end)) end = last.end;
var words = line.words;
if (last.end == null || last.end! <= last.time) {
// An explicit line end still bounds an untimed final word.
final estimate = end != null && end > last.time
? end
: _estimatedFinalWordEnd(
line.words,
_nextBoundary(timeline, i),
nearGap,
);
words = [
...line.words.take(line.words.length - 1),
LyricWord(time: last.time, end: estimate, text: last.text),
];
if (end == null || end < estimate) end = estimate;
}
if (end == line.end && identical(words, line.words)) {
result.add(line);
continue;
}
result.add(
LyricLine(
time: line.time,
end: end,
text: line.text,
words: words,
romanization: line.romanization,
romanizationWords: line.romanizationWords,
translation: line.translation,
voice: line.voice,
isBackground: line.isBackground,
vocalGroup: line.vocalGroup,
),
);
}
return List.unmodifiable(result);
}
/// The next lead line or countdown after [index]; backing parts and singers
/// starting together do not end a line.
Duration? _nextBoundary(List<LyricLine> lines, int index) {
final start = lines[index].time;
for (var i = index + 1; i < lines.length; i++) {
final line = lines[i];
if (!line.isBackground && line.time > start) return line.time;
}
return null;
}
Duration _estimatedFinalWordEnd(
List<LyricWord> words,
Duration? next,
Duration nearGap,
) {
final start = words.last.time;
final paces = <int>[
for (var i = 0; i + 1 < words.length; i++)
((words[i].end ?? words[i + 1].time) - words[i].time).inMilliseconds,
].where((value) => value > 0).toList()..sort();
final pace = paces.isEmpty ? 600 : paces[paces.length ~/ 2];
// A final word is usually held longer than the line's typical syllable.
final estimate =
start + Duration(milliseconds: (pace * 2.5).clamp(700, 3000).round());
if (next == null) return estimate;
if (next <= start) {
return start + Duration(milliseconds: pace.clamp(150, 400));
}
if (next - start <= nearGap) return next;
return estimate;
}
+22
View File
@@ -109,3 +109,25 @@ double syncedLyricSegmentLift({
final settle = ease((duration - elapsed) / 600);
return normalLift + 1.3 * heldStrength * rise * settle;
}
/// Glow strength from 0 to 1 for a word held for a long time. Short words
/// never glow; a held word brightens while it is sung, then fades shortly
/// after its end instead of switching off with the sweep.
double syncedLyricSegmentGlow({
required Duration position,
required Duration start,
required Duration end,
}) {
final duration = (end - start).inMicroseconds / 1000;
final strength = ((duration - 900) / 1100).clamp(0.0, 1.0);
if (strength == 0) return 0;
final elapsed = (position - start).inMicroseconds / 1000;
double ease(double value) {
final t = value.clamp(0.0, 1.0);
return t * t * (3 - 2 * t);
}
final rise = ease((elapsed - 250) / math.min(900, duration * 0.5));
final fade = ease((duration + 500 - elapsed) / 500);
return strength * rise * fade;
}
@@ -28,3 +28,38 @@ class LyricSupplementTransition extends StatelessWidget {
),
);
}
/// Backing vocals drop out from under their lead line: the row opens while
/// the full-size text slides down into it, instead of growing in place.
class LyricBackingTransition extends StatelessWidget {
const LyricBackingTransition({
super.key,
required this.visibility,
required this.child,
this.alignment = Alignment.topLeft,
});
final double visibility;
final Widget child;
final Alignment alignment;
@override
Widget build(BuildContext context) {
// Text emerges from the lead's lower edge, so clip to the opening row.
// A shown row keeps the same tree unclipped, leaving blur halos intact.
return ClipRect(
clipBehavior: visibility >= 1 ? Clip.none : Clip.hardEdge,
child: Align(
alignment: alignment,
heightFactor: visibility,
child: Opacity(
opacity: Curves.easeOut.transform(visibility.clamp(0.0, 1.0)),
child: FractionalTranslation(
translation: Offset(0, visibility - 1),
child: child,
),
),
),
);
}
}
@@ -51,10 +51,12 @@ class MornyePlayerActionsSheet extends ConsumerWidget {
super.key,
required this.mediaItem,
this.sleepTimerSubtitle,
this.canShareLyrics = false,
});
final MediaItem mediaItem;
final String? sleepTimerSubtitle;
final bool canShareLyrics;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -116,6 +118,13 @@ class MornyePlayerActionsSheet extends ConsumerWidget {
context.l10n.nowPlayingDetails,
CupertinoIcons.info,
),
if (canShareLyrics)
_action(
context,
'shareLyrics',
context.l10n.lyricsShareTitle,
CupertinoIcons.quote_bubble,
),
],
[
_action(
+127 -41
View File
@@ -4,6 +4,7 @@ import 'dart:ui' show BoxHeightStyle, ImageFilter;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show ValueListenable, listEquals;
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:flutter/rendering.dart'
show OverflowBoxFit, RenderAbstractViewport, ScrollDirection;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -34,6 +35,9 @@ class SyncedLyricsView extends ConsumerStatefulWidget {
final bool showPronunciation;
final bool showTranslation;
/// Long-pressing a sung line starts sharing it.
final ValueChanged<LyricLine>? onLongPressLine;
const SyncedLyricsView({
super.key,
required this.lyrics,
@@ -43,6 +47,7 @@ class SyncedLyricsView extends ConsumerStatefulWidget {
required this.isActive,
required this.showPronunciation,
required this.showTranslation,
this.onLongPressLine,
});
@override
@@ -62,6 +67,9 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
late List<GlobalKey> _lineKeys;
int _active = -1;
Set<int> _activeLines = {};
/// The line the view scrolls to; see [_focusFor].
int _focus = -1;
Duration _activeTransitionPosition = Duration.zero;
bool _playing = false;
bool _loading = false;
@@ -198,7 +206,7 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
_hasStarted = false;
_lineExtents = null;
_lineLayoutKey = null;
_lines = lyricsTimelineWithGaps(widget.lyrics.lines);
_lines = lyricsWithDisplayEnds(lyricsTimelineWithGaps(widget.lyrics.lines));
_rowReveal.stop();
_revealFrom = _revealTo = [
for (final line in _lines)
@@ -234,6 +242,7 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
_loading = ref.read(playbackLoadingProvider);
_active = _activeIndexAt(position);
_activeLines = activeLyricIndices(_lines, position, _active);
_focus = _focusFor(_active, _activeLines, position);
_updateRowVisibility(immediate: true);
_activeTransitionPosition = position;
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -306,31 +315,46 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
return LyricsParser.activeIndex(_lines, position);
}
/// The scroll anchor for [active]. When the next line starts while the
/// previous one is still being sung, the view holds on the earlier line
/// until its timed end, so both stay fully visible instead of the unfinished
/// one scrolling away. Only the directly preceding lead line can hold.
int _focusFor(int active, Set<int> activeLines, Duration position) {
if (active < 0) return -1;
final focus = _displayLayout.focusForLine[active];
final start = _lines[focus].time;
for (var i = focus - 1; i >= 0; i--) {
final line = _lines[i];
if (line.isBackground || line.time >= start) continue;
if (line.text.isEmpty) return focus;
final end = line.end;
final held = _displayLayout.focusForLine[i];
return activeLines.contains(i) && end != null && end > position
? held
: focus;
}
return focus;
}
void _setActiveLine(int active, {required Duration position}) {
if (!mounted) return;
final activeLines = activeLyricIndices(_lines, position, active);
final indexChanged = active != _active;
final previousFocus = _active < 0
? -1
: _displayLayout.focusForLine[_active];
final previousFocus = _focus;
if (!indexChanged &&
activeLines.length == _activeLines.length &&
activeLines.containsAll(_activeLines)) {
return;
}
final focus = _focusFor(active, activeLines, position);
setState(() {
_active = active;
_activeLines = activeLines;
_focus = focus;
_activeTransitionPosition = position;
_updateRowVisibility(
sameFocus:
active >= 0 && _displayLayout.focusForLine[active] == previousFocus,
);
_updateRowVisibility(sameFocus: focus >= 0 && focus == previousFocus);
});
if (!indexChanged ||
(active >= 0 && _displayLayout.focusForLine[active] == previousFocus)) {
return;
}
if (focus == previousFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && active == _active) unawaited(_maybeAutoScroll(active));
});
@@ -468,7 +492,9 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
!_scroll.hasClients) {
return;
}
index = _displayLayout.focusForLine[index];
index = index == _active && _focus >= 0
? _focus
: _displayLayout.focusForLine[index];
final row = _displayLayout.rowForLine[index];
final duration = immediate || MediaQuery.disableAnimationsOf(context)
? Duration.zero
@@ -539,7 +565,7 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
}
if (!mounted ||
_userScrolling ||
(_active >= 0 && index != _displayLayout.focusForLine[_active]) ||
(_active >= 0 && index != _focus) ||
index >= _lineKeys.length) {
return;
}
@@ -671,10 +697,7 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
!listEquals(_layoutRowVisibility, rowVisibility))) &&
previousExtents.length == _lineExtents!.length &&
_scroll.hasClients) {
var anchor = _active < 0
? 0
: _displayLayout.rowForLine[_displayLayout
.focusForLine[_active]];
var anchor = _focus < 0 ? 0 : _displayLayout.rowForLine[_focus];
if (_userScrolling) {
anchor = 0;
var extent = 0.0;
@@ -847,9 +870,7 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
),
);
if (mornye) {
final activeLead = active < 0
? -1
: _displayLayout.focusForLine[active];
final activeLead = active < 0 ? -1 : _focus;
final distance = activeLead < 0
? row + 1
: (_displayLayout.rowForLine[leadIndex] -
@@ -899,6 +920,12 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
child: GestureDetector(
onTap: () =>
ref.read(musicPlayerControllerProvider).seek(line.time),
onLongPress: widget.onLongPressLine == null
? null
: () {
HapticFeedback.mediumImpact();
widget.onLongPressLine!(line);
},
child: AnimatedScale(
scale: isActive ? 1.0 : 0.96,
alignment: !mornye
@@ -932,29 +959,37 @@ class _SyncedLyricsViewState extends ConsumerState<SyncedLyricsView>
TextAlign align,
Widget child,
) {
final alignment = align == TextAlign.right
? Alignment.topRight
: align == TextAlign.center
? Alignment.topCenter
: Alignment.topLeft;
// A fixed-extent row is already shrinking. Measure its contents at full
// height so fitting does not scale it twice.
final fullHeight = OverflowBox(
minHeight: 0,
maxHeight: double.infinity,
fit: OverflowBoxFit.deferToChild,
alignment: Alignment.topCenter,
child: child,
);
return KeyedSubtree(
key: _lineKeys[index],
child: !_lines[index].isBackground && _lines[index].text.isNotEmpty
? child
: IgnorePointer(
ignoring: visibility < 1,
child: LyricSupplementTransition(
visibility: visibility,
alignment: align == TextAlign.right
? Alignment.topRight
: align == TextAlign.center
? Alignment.topCenter
: Alignment.topLeft,
// A fixed-extent row is already shrinking. Measure its
// contents at full height so fitting does not scale it twice.
child: OverflowBox(
minHeight: 0,
maxHeight: double.infinity,
fit: OverflowBoxFit.deferToChild,
alignment: Alignment.topCenter,
child: child,
),
),
child: _lines[index].isBackground
? LyricBackingTransition(
visibility: visibility,
alignment: alignment,
child: fullHeight,
)
: LyricSupplementTransition(
visibility: visibility,
alignment: alignment,
child: fullHeight,
),
),
);
}
@@ -1550,6 +1585,9 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> {
? (textScaler.scale(widget.style.fontSize ?? 24) * 0.05)
.clamp(0.0, 2.0)
: 0,
glowSigma: widget.liftEnabled
? textScaler.scale(widget.style.fontSize ?? 24) * 0.2
: 0,
),
),
);
@@ -1567,6 +1605,9 @@ class _TimedLyricSweepPainter extends CustomPainter {
final TextPainter highlightedPainter;
final double highlightLift;
/// Blur radius of the glow behind held words; 0 disables it.
final double glowSigma;
_TimedLyricSweepPainter({
required this.segmentBoxes,
required this.starts,
@@ -1576,6 +1617,7 @@ class _TimedLyricSweepPainter extends CustomPainter {
required this.pendingPainter,
required this.highlightedPainter,
required this.highlightLift,
required this.glowSigma,
}) : super(repaint: repaint);
@override
@@ -1583,6 +1625,7 @@ class _TimedLyricSweepPainter extends CustomPainter {
final pendingPaths = <double, Path>{};
final completedPaths = <double, Path>{};
final partialBoxes = <(Rect, double, double, double)>[];
final glows = <(Path, double, double)>[];
final position = currentPosition();
for (var index = 0; index < segmentBoxes.length; index++) {
final timed = index < starts.length && index < ends.length;
@@ -1603,6 +1646,14 @@ class _TimedLyricSweepPainter extends CustomPainter {
end: ends[index],
)
: 0.0;
final glow = glowSigma > 0 && timed && value > 0
? syncedLyricSegmentGlow(
position: position,
start: starts[index],
end: ends[index],
)
: 0.0;
final glowPath = glow > 0.01 ? Path() : null;
var consumed = 0.0;
for (final box in boxes) {
if (box.width <= 0) continue;
@@ -1612,10 +1663,22 @@ class _TimedLyricSweepPainter extends CustomPainter {
// Keep the color sweep continuous across wrapping and font fallback,
// independently of the movement shared by the whole word.
final revealWidth = width * value - consumed;
final feather = ((highlightLift > 0 ? width : box.width) * 0.18).clamp(
3.0,
10.0,
// A wide, soft leading edge reads as a smooth flow, not a wipe.
final feather = ((highlightLift > 0 ? width : box.width) * 0.3).clamp(
4.0,
16.0,
);
// Only the sung part glows, so unsung letters never light up early.
if (glowPath != null && revealWidth > 0) {
glowPath.addRect(
Rect.fromLTRB(
box.left,
box.top,
box.left + revealWidth.clamp(0.0, box.width),
box.bottom,
),
);
}
if (value > 0 && revealWidth >= box.width) {
completedPaths.putIfAbsent(lift, Path.new).addRect(box);
} else if (value > 0 && revealWidth > -feather) {
@@ -1623,6 +1686,7 @@ class _TimedLyricSweepPainter extends CustomPainter {
}
consumed += box.width;
}
if (glowPath != null) glows.add((glowPath, lift, glow));
}
// Move both colors together, so a raised highlight never leaves a dim
@@ -1634,6 +1698,27 @@ class _TimedLyricSweepPainter extends CustomPainter {
} else {
pendingPainter.paint(canvas, Offset.zero);
}
// A blurred copy of the sung letters behind them. Usually at most one
// held word glows, so the extra layer stays cheap.
for (final (clip, lift, glow) in glows) {
final bounds = clip.getBounds().inflate(glowSigma * 3);
canvas.save();
canvas.translate(0, -lift);
canvas.saveLayer(
bounds,
Paint()
..color = Color.fromRGBO(0, 0, 0, 0.55 * glow)
..imageFilter = ImageFilter.blur(
sigmaX: glowSigma,
sigmaY: glowSigma,
),
);
canvas.clipPath(clip, doAntiAlias: false);
highlightedPainter.paint(canvas, Offset.zero);
canvas.restore();
canvas.restore();
}
for (final entry in completedPaths.entries) {
_paintLiftedText(canvas, highlightedPainter, entry.value, entry.key);
}
@@ -1688,6 +1773,7 @@ class _TimedLyricSweepPainter extends CustomPainter {
oldDelegate.ends != ends ||
oldDelegate.currentPosition != currentPosition ||
oldDelegate.highlightLift != highlightLift ||
oldDelegate.glowSigma != glowSigma ||
oldDelegate.pendingPainter != pendingPainter ||
oldDelegate.highlightedPainter != highlightedPainter;
}
+22 -7
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
@@ -18,6 +20,7 @@ class PlayerLyricsSection extends ConsumerWidget {
required this.colorScheme,
required this.isActive,
this.options,
this.onShareLyrics,
});
final PlayerMetadataController metadata;
@@ -26,6 +29,9 @@ class PlayerLyricsSection extends ConsumerWidget {
final bool isActive;
final Widget? options;
/// Long-pressing lyrics opens sharing, preselecting a timed line.
final ValueChanged<LyricLine?>? onShareLyrics;
@override
Widget build(BuildContext context, WidgetRef ref) {
final visibility = ref.watch(
@@ -77,6 +83,7 @@ class PlayerLyricsSection extends ConsumerWidget {
isActive: isActive,
showPronunciation: visibility.$1,
showTranslation: visibility.$2,
onLongPressLine: onShareLyrics,
),
if (options != null)
Positioned(
@@ -92,14 +99,22 @@ class PlayerLyricsSection extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LyricsTextScale(
child: Text(
metadata.lyrics.plainText,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
height: 1.6,
color: colorScheme.onSurface,
GestureDetector(
onLongPress: onShareLyrics == null
? null
: () {
HapticFeedback.mediumImpact();
onShareLyrics!(null);
},
child: LyricsTextScale(
child: Text(
metadata.lyrics.plainText,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
height: 1.6,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
),
?_lyricsCredits(),
+85
View File
@@ -0,0 +1,85 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/screens/lyrics_share_screen.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
void main() {
test('timed lyrics share their sung lines, plain lyrics their text', () {
final timed = LyricsParser.parse('''
[00:01.00]First line
[00:03.00]
[00:05.00]Second line
''');
expect(lyricsShareLines(timed).map((line) => line.text), [
'First line',
'Second line',
]);
final plain = LyricsParser.parse('Verse one\n\nVerse two\n');
expect(lyricsShareLines(plain).map((line) => line.text), [
'Verse one',
'Verse two',
]);
});
test('short quotes use larger type than long ones', () {
expect(
LyricsShareCard.fontSizeFor([(text: 'Short', background: false)]),
greaterThan(
LyricsShareCard.fontSizeFor([(text: 'A' * 200, background: false)]),
),
);
});
testWidgets('selection is limited and required before sharing', (
tester,
) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: LyricsShareScreen(
mediaItem: const MediaItem(id: 'track', title: 'Track'),
lines: [
for (var i = 0; i < 7; i++) (text: 'Line $i', background: false),
],
),
),
);
await tester.pumpAndSettle();
ExpressiveButton share() => tester.widget<ExpressiveButton>(
find.ancestor(
of: find.text('Share'),
matching: find.byType(ExpressiveButton),
),
);
expect(share().onPressed, isNull);
Finder choice(int index) => find.descendant(
of: find.byType(ListView),
matching: find.text('Line $index'),
);
for (var i = 0; i < lyricsShareMaxLines; i++) {
await tester.tap(choice(i));
}
await tester.pumpAndSettle();
expect(share().onPressed, isNotNull);
// The card quotes each selected line once.
expect(find.text('Line 0'), findsNWidgets(2));
await tester.scrollUntilVisible(choice(6), 100);
// Fully reveal the row; a partly clipped one cannot be tapped.
await tester.ensureVisible(choice(6));
await tester.pumpAndSettle();
await tester.tap(choice(6));
await tester.pump();
expect(find.text('You can share up to 5 lines'), findsOneWidget);
expect(find.text('Line 6'), findsOneWidget);
});
}
+43
View File
@@ -3,6 +3,49 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/lyrics_timeline.dart';
void main() {
group('display ends for an unanchored final word', () {
List<LyricLine> display(String lrc) => lyricsWithDisplayEnds(
lyricsTimelineWithGaps(LyricsParser.parse(lrc).lines),
);
test('a close next line receives the remaining time', () {
final lines = display('''
[00:01.00]<00:01.00>Hold <00:01.40>on
[00:04.50]<00:04.50>Next<00:05.00>
''');
expect(lines.first.words.last.end, const Duration(milliseconds: 4500));
expect(lines.first.end, const Duration(milliseconds: 4500));
});
test('a distant next line ends the word from the line pace', () {
final lines = display('''
[00:01.00]<00:01.00>Hold <00:01.40>on
[00:20.00]<00:20.00>Next<00:21.00>
''');
// 400 ms per word, held 2.5 times as long as a typical word.
expect(lines.first.words.last.end, const Duration(milliseconds: 2400));
});
test('explicit word and line ends are kept', () {
final lines = display('''
[00:01.00]<00:01.00>Hold <00:01.40>on<00:02.00>
[00:03.00]<00:03.00>Next<00:03.50>
''');
expect(lines.first.words.last.end, const Duration(seconds: 2));
expect(lines.first.end, const Duration(seconds: 2));
});
test('a blank timestamp bounds the final word', () {
final lines = display('''
[00:01.00]<00:01.00>Hold <00:01.40>on
[00:03.00]
[00:12.00]<00:12.00>Next<00:13.00>
''');
expect(lines[0].words.last.end, const Duration(seconds: 3));
expect(lines[1].text, isEmpty);
});
});
test('simultaneous untimed singers share highlighting and scroll focus', () {
final lines = LyricsParser.parse('''
[00:01.00]v1:Lead
@@ -3756,6 +3756,60 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('an unfinished line holds the view while the next starts '
'($mornye)', (tester) async {
metadataOverrides['lyrics'] =
'[00:01.00]<00:01.00>Long <00:02.00>held<00:06.00>\n'
'[00:04.00]<00:04.00>Next<00:05.00>\n'
'[00:09.00]<00:09.00>Later<00:10.00>';
final playback = StreamController<PlaybackState>.broadcast();
addTearDown(playback.close);
await pumpNowPlaying(
tester,
theme: mornye ? MornyeTheme.build(Brightness.dark) : null,
size: const Size(390, 844),
playbackEvents: playback.stream,
);
mediaItems.add(item('first'));
await settlePlayer(tester);
await tester.tap(
mornye
? findAppIcon(CupertinoIcons.quote_bubble)
: find.byKey(const ValueKey('material-lyrics-toggle')),
);
await settlePlayer(tester);
Finder timed(String text) => find.descendant(
of: find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.label == text,
),
matching: find.byType(CustomPaint),
);
Future<void> seek(int milliseconds) async {
playback.add(
PlaybackState(
playing: false,
processingState: AudioProcessingState.ready,
updatePosition: Duration(milliseconds: milliseconds),
),
);
await settlePlayer(tester);
}
await seek(3500);
final held = tester.getTopLeft(timed('Long held')).dy;
await seek(4500);
// Both lines keep their word sweep, and the view does not move.
expect(timed('Long held'), findsOneWidget);
expect(timed('Next'), findsOneWidget);
expect(tester.getTopLeft(timed('Long held')).dy, held);
await seek(6500);
expect(timed('Long held'), findsNothing);
expect(timed('Next'), findsOneWidget);
expect(tester.getTopLeft(timed('Next')).dy, lessThan(held + 1));
expect(tester.takeException(), isNull);
});
testWidgets(
'original and romanization follow word timing and pauses ($mornye)',
(tester) async {
+15
View File
@@ -54,6 +54,21 @@ void main() {
);
});
test('only held words glow, rising while sung and fading after', () {
double glowAt(int position, {required int end}) => syncedLyricSegmentGlow(
position: Duration(milliseconds: position),
start: const Duration(seconds: 1),
end: Duration(milliseconds: end),
);
expect(glowAt(1300, end: 1500), 0);
expect(glowAt(1100, end: 4000), 0);
expect(glowAt(1800, end: 4000), inExclusiveRange(0, 1));
expect(glowAt(3000, end: 4000), 1);
expect(glowAt(4250, end: 4000), inExclusiveRange(0, 1));
expect(glowAt(4600, end: 4000), 0);
});
group('smooth timed lyric highlight', () {
double liftAt(int position, {int end = 1500}) => syncedLyricSegmentLift(
position: Duration(milliseconds: position),