mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 15:28:45 +02:00
feat(lyrics): share lyric cards and refine timed vocal transitions
This commit is contained in:
1 parent
40f65d3a4d
commit
78a5e078cd
12 files changed
+1213
-48
No files matched your search
@@ -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)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user