feat(lyrics): refresh quote sharing and load lyrics on demand

This commit is contained in:
zarzet committed 2026-10-18 03:10:53 +07:00
1 parent 75cd4c39b2
commit 2782b1f7f2
4 files changed
+423 -160

No files matched your search

+324 -137
View File
@@ -4,6 +4,8 @@ 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:material_3_expressive/material_3_expressive.dart'
show M3EShapeKind;
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';
@@ -12,6 +14,7 @@ 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/app_shape.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
@@ -99,7 +102,7 @@ class _LyricsShareScreenState extends State<LyricsShareScreen> {
late final ScrollController _list = ScrollController(
initialScrollOffset: widget.initialSelection.isEmpty
? 0
: (widget.initialSelection.first - 2).clamp(0, 1 << 20) * 52.0,
: (widget.initialSelection.first - 2).clamp(0, 1 << 20) * 58.0,
);
LyricsCardBackground _background = LyricsCardBackground.artwork;
bool _sharing = false;
@@ -165,7 +168,7 @@ class _LyricsShareScreenState extends State<LyricsShareScreen> {
widget.mediaItem.title,
if ((widget.mediaItem.artist ?? '').trim().isNotEmpty)
widget.mediaItem.artist!.trim(),
].join(' — '),
].join(' · '),
sharePositionOrigin: origin,
),
);
@@ -210,27 +213,39 @@ class _LyricsShareScreenState extends State<LyricsShareScreen> {
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,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.22),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(28),
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,
),
),
),
),
@@ -241,23 +256,25 @@ class _LyricsShareScreenState extends State<LyricsShareScreen> {
),
),
),
_BackgroundPicker(
value: _background,
onChanged: (value) => setState(() => _background = value),
),
Padding(
padding: EdgeInsets.fromLTRB(
tokens.gapXl,
tokens.gapLg,
tokens.gapSm,
tokens.gapLg,
tokens.gapSm,
tokens.gapXl,
tokens.gapXs,
),
child: Text(
l10n.lyricsShareSelectHint(lyricsShareMaxLines),
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
child: Row(
children: [
Expanded(
child: Text(
l10n.lyricsShareSelectHint(lyricsShareMaxLines),
style: theme.textTheme.titleSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
_SelectionCount(count: _selected.length),
],
),
),
Expanded(
@@ -269,25 +286,45 @@ class _LyricsShareScreenState extends State<LyricsShareScreen> {
itemBuilder: (context, index) => _LineChoice(
line: widget.lines[index],
selected: _selected.contains(index),
first: index == 0,
last: index == widget.lines.length - 1,
onTap: () => _toggle(index),
),
),
),
// Floating toolbar: background swatches beside the primary action.
Padding(
padding: EdgeInsets.fromLTRB(
tokens.gapLg,
tokens.gapMd,
tokens.gapSm,
tokens.gapLg,
tokens.gapLg,
tokens.gapMd,
tokens.gapMd,
),
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),
),
child: Row(
children: [
Expanded(
child: _BackgroundPicker(
value: _background,
onChanged: (value) => setState(() => _background = value),
),
),
const SizedBox(width: 8),
ExpressiveButton(
key: _shareButtonKey,
icon: const AppIcon(Icons.ios_share),
style: ButtonStyle(
minimumSize: const WidgetStatePropertyAll(Size(64, 64)),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 24),
),
textStyle: WidgetStatePropertyAll(
theme.textTheme.titleMedium,
),
),
onPressed: _selected.isEmpty || _sharing ? null : _share,
child: Text(l10n.lyricsShareAction),
),
],
),
),
],
@@ -327,52 +364,121 @@ class _BackgroundPicker extends StatelessWidget {
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,
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return Container(
height: 64,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: ShapeDecoration(
color: colors.surfaceContainerHigh,
shape: const StadiumBorder(),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final option in LyricsCardBackground.values)
Expanded(
child: Semantics(
button: true,
selected: option == value,
inMutuallyExclusiveGroup: true,
label: labels[option],
child: Tooltip(
message: labels[option],
excludeFromSemantics: true,
// The whole cell stays tappable; the ripple lives inside
// the swatch so it takes the swatch's shape.
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(option),
child: SizedBox(
height: 52,
child: Center(
// The chosen swatch morphs from a circle into a larger
// rounded square, the Expressive selection cue.
child: AnimatedContainer(
duration: reduceMotion
? Duration.zero
: const Duration(milliseconds: 260),
curve: Curves.easeOutBack,
width: option == value ? 42 : 32,
height: option == value ? 42 : 32,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
option == value ? 14 : 17,
),
gradient: swatches[option],
border: Border.all(
color: option == value
? colors.primary
: colors.outlineVariant,
width: option == value ? 2.5 : 1,
),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () => onChanged(option),
child: Center(
child: option == value
? AppIcon(
Icons.check_rounded,
size: 20,
color:
option == LyricsCardBackground.light
? const Color(0xFF111111)
: Colors.white,
)
: option == LyricsCardBackground.artwork
? const AppIcon(
Icons.image_outlined,
size: 16,
color: Colors.white,
)
: null,
),
),
),
),
),
child: option == LyricsCardBackground.artwork
? const AppIcon(
Icons.image_outlined,
size: 16,
color: Colors.white,
)
: null,
),
),
),
),
),
),
],
],
),
);
}
}
/// How many lines are quoted, against the card's limit.
class _SelectionCount extends StatelessWidget {
const _SelectionCount({required this.count});
final int count;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final active = count > 0;
return AnimatedContainer(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: ShapeDecoration(
color: active ? colors.primaryContainer : colors.surfaceContainerHigh,
shape: const StadiumBorder(),
),
child: Text(
'$count/$lyricsShareMaxLines',
style: theme.textTheme.labelLarge?.copyWith(
color: active ? colors.onPrimaryContainer : colors.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
);
}
}
@@ -381,51 +487,105 @@ class _LineChoice extends StatelessWidget {
const _LineChoice({
required this.line,
required this.selected,
required this.first,
required this.last,
required this.onTap,
});
final LyricsShareLine line;
final bool selected;
final bool first;
final bool last;
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,
),
// Segmented list: rows sit 2dp apart with large outer corners and small
// inner ones; a selected row rounds fully and turns tonal.
const outer = Radius.circular(20);
const inner = Radius.circular(6);
final radius = selected
? const BorderRadius.all(outer)
: BorderRadius.vertical(
top: first ? outer : inner,
bottom: last ? outer : inner,
);
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 220);
return Padding(
padding: EdgeInsets.only(bottom: last ? 0 : 2),
child: MergeSemantics(
child: Semantics(
checked: selected,
// The container clips its own ink, so the press highlight follows
// the corners while they animate.
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: selected
? colors.secondaryContainer
: colors.surfaceContainerLow,
borderRadius: radius,
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 56),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
children: [
AnimatedContainer(
duration: duration,
curve: Curves.easeOutBack,
width: 24,
height: 24,
decoration: BoxDecoration(
color: selected ? colors.primary : null,
borderRadius: BorderRadius.circular(
selected ? 8 : 12,
),
border: selected
? null
: Border.all(color: colors.outline, width: 2),
),
child: selected
? AppIcon(
Icons.check_rounded,
size: 18,
color: colors.onPrimary,
)
: null,
),
const SizedBox(width: 16),
Expanded(
child: Text(
line.text,
style:
(line.background
? theme.textTheme.bodyMedium
: theme.textTheme.bodyLarge)
?.copyWith(
color: selected
? colors.onSecondaryContainer
: colors.onSurface,
),
),
),
],
),
),
],
),
),
),
),
@@ -504,19 +664,20 @@ class LyricsShareCard extends StatelessWidget {
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
AppShapeClip(
kind: M3EShapeKind.cookie4Sided,
fallbackRadius: BorderRadius.circular(10),
child: SizedBox.square(
dimension: 44,
dimension: 52,
child: PlayerArtwork(
artUri: artUri,
colorScheme: scheme,
iconSize: 22,
cacheWidth: 132,
iconSize: 24,
cacheWidth: 156,
),
),
),
const SizedBox(width: 12),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -527,8 +688,8 @@ class LyricsShareCard extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: 15,
fontWeight: FontWeight.w700,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
if (artist.isNotEmpty)
@@ -562,7 +723,7 @@ class LyricsShareCard extends StatelessWidget {
alpha: 0.4,
),
fontSize: 20,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
),
)
: Column(
@@ -583,9 +744,11 @@ class LyricsShareCard extends StatelessWidget {
fontSize: line.background
? fontSize * 0.72
: fontSize,
height: 1.18,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
height: 1.2,
fontWeight: line.background
? FontWeight.w400
: FontWeight.w500,
letterSpacing: -0.2,
),
),
),
@@ -599,13 +762,37 @@ class LyricsShareCard extends StatelessWidget {
),
const Spacer(),
Center(
child: Text(
'SpotiFLAC',
style: TextStyle(
color: secondary,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
child: Container(
padding: const EdgeInsets.fromLTRB(6, 6, 14, 6),
decoration: ShapeDecoration(
color: foreground.withValues(
alpha: light ? 0.06 : 0.14,
),
shape: const StadiumBorder(),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// The asset is already a disc with the glyph cut
// out, so it only needs the card's text color.
Image.asset(
'assets/images/logo-transparent.png',
width: 26,
height: 26,
color: foreground,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
const SizedBox(width: 8),
Text(
'SpotiFLAC-Mobile',
style: TextStyle(
color: foreground,
fontSize: 13,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
],
),
),
),
+53 -14
View File
@@ -91,6 +91,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
bool _lyricsControlsHidden = false;
bool _lyricsOptionsOpen = false;
bool _audioOutputOpen = false;
bool _sharingLyrics = false;
double _lyricsScrollDistance = 0;
Timer? _lyricsIdleTimer;
final _lyricsPointers = <int, Offset>{};
@@ -720,13 +721,54 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
);
Future<void> _shareLyrics([LyricLine? line]) async {
if (_sharingLyrics) return;
final mediaItem = ref.read(currentMediaItemProvider).value;
if (mediaItem == null || _trackMetadata.lyrics.isEmpty) return;
await openLyricsShare(
context,
mediaItem: mediaItem,
lyrics: _trackMetadata.lyrics,
initial: line,
if (mediaItem == null) return;
_sharingLyrics = true;
try {
// The menu offers sharing before the lyrics page has read the file, so
// read it now (including an unresolved document) and wait for parsing.
if (_trackMetadata.lyrics.isEmpty) {
await _trackMetadata.load(mediaItem, inspectUnresolvedContentUri: true);
await _untilMetadataLoaded();
}
if (!mounted ||
ref.read(currentMediaItemProvider).value?.id != mediaItem.id) {
return;
}
if (_trackMetadata.lyrics.isEmpty) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(context.l10n.nowPlayingNoLyrics)),
);
return;
}
await openLyricsShare(
context,
mediaItem: mediaItem,
lyrics: _trackMetadata.lyrics,
initial: line,
);
} finally {
_sharingLyrics = false;
}
}
/// Completes once a load already in flight (started elsewhere) finishes.
Future<void> _untilMetadataLoaded() {
if (!_trackMetadata.loading) return Future.value();
final done = Completer<void>();
void listener() {
if (_trackMetadata.loading || done.isCompleted) return;
_trackMetadata.removeListener(listener);
done.complete();
}
_trackMetadata.addListener(listener);
return done.future.timeout(
const Duration(seconds: 10),
onTimeout: () => _trackMetadata.removeListener(listener),
);
}
@@ -960,7 +1002,6 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
builder: (_) => MornyePlayerActionsSheet(
mediaItem: mediaItem,
sleepTimerSubtitle: sleepTimerSubtitle,
canShareLyrics: !_trackMetadata.lyrics.isEmpty,
),
)
: await showAppBottomSheet<String>(
@@ -984,13 +1025,11 @@ 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.format_quote_outlined,
title: sheetContext.l10n.lyricsShareTitle,
onTap: () => Navigator.of(sheetContext).pop('shareLyrics'),
),
SettingsItem(
icon: Icons.bedtime_outlined,
title: sheetContext.l10n.nowPlayingSleepTimer,
+6 -9
View File
@@ -51,12 +51,10 @@ 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) {
@@ -118,13 +116,12 @@ class MornyePlayerActionsSheet extends ConsumerWidget {
context.l10n.nowPlayingDetails,
CupertinoIcons.info,
),
if (canShareLyrics)
_action(
context,
'shareLyrics',
context.l10n.lyricsShareTitle,
CupertinoIcons.quote_bubble,
),
_action(
context,
'shareLyrics',
context.l10n.lyricsShareTitle,
CupertinoIcons.quote_bubble,
),
],
[
_action(
@@ -27,6 +27,7 @@ import 'package:spotiflac_android/services/network_metadata_service.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'package:video_player/video_player.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/screens/lyrics_share_screen.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
@@ -3061,6 +3062,45 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('sharing reads document lyrics before opening the lyrics page', (
tester,
) async {
await pumpNowPlaying(tester);
mediaItems.add(item('first'));
await settlePlayer(tester);
expect(metadataReads, isEmpty);
await tester.tap(findAppIcon(Icons.more_vert));
await settlePlayer(tester);
await tester.tap(find.text('Share lyrics'));
await settlePlayer(tester);
expect(metadataReads, ['content://library/first.flac']);
expect(find.byType(LyricsShareScreen), findsOneWidget);
expect(find.text('First lyric'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a track change cancels a pending lyrics share', (tester) async {
await pumpNowPlaying(tester);
mediaItems.add(item('first'));
await settlePlayer(tester);
await tester.tap(findAppIcon(Icons.more_vert));
await settlePlayer(tester);
await tester.tap(find.text('Share lyrics'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(lyricsRequests, isNotEmpty);
mediaItems.add(item('second'));
await tester.pump();
await settlePlayer(tester);
expect(find.byType(LyricsShareScreen), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('network lyrics use shared metadata instead of the audio URL', (
tester,
) async {