mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components. Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures. Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
563 lines
18 KiB
Dart
563 lines
18 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
|
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
|
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
|
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
|
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
|
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
|
|
|
Future<void> showAddTrackToPlaylistSheet(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
Track track,
|
|
) async {
|
|
return showAddTracksToPlaylistSheet(context, ref, [track]);
|
|
}
|
|
|
|
Future<void> showAddTracksToPlaylistSheet(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
List<Track> tracks, {
|
|
String? playlistNamePrefill,
|
|
}) async {
|
|
if (tracks.isEmpty) return;
|
|
|
|
if (!context.mounted) return;
|
|
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
showDragHandle: !context.isMornye,
|
|
backgroundColor: context.isMornye ? Colors.transparent : null,
|
|
isScrollControlled: true,
|
|
builder: (sheetContext) {
|
|
final content = _PlaylistPickerSheetContent(
|
|
tracks: tracks,
|
|
playlistNamePrefill: playlistNamePrefill,
|
|
);
|
|
return sheetContext.isMornye
|
|
? MornyeGlassPanel(tintOpacity: 0.78, child: content)
|
|
: content;
|
|
},
|
|
);
|
|
}
|
|
|
|
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
|
|
final List<Track> tracks;
|
|
final String? playlistNamePrefill;
|
|
|
|
const _PlaylistPickerSheetContent({
|
|
required this.tracks,
|
|
this.playlistNamePrefill,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<_PlaylistPickerSheetContent> createState() =>
|
|
_PlaylistPickerSheetContentState();
|
|
}
|
|
|
|
class _PlaylistPickerSheetContentState
|
|
extends ConsumerState<_PlaylistPickerSheetContent> {
|
|
late final PlaylistPickerSummaryRequest _summaryRequest;
|
|
final Set<String> _selectedPlaylistIds = {};
|
|
final Set<String> _committedPlaylistIds = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_summaryRequest = PlaylistPickerSummaryRequest.fromTracks(widget.tracks);
|
|
}
|
|
|
|
void _handleDone(List<PlaylistPickerSummary> playlists) async {
|
|
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
|
final effectiveDisabledIds = <String>{
|
|
..._committedPlaylistIds,
|
|
for (final playlist in playlists)
|
|
if (playlist.containsAllRequestedTracks) playlist.id,
|
|
};
|
|
final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds);
|
|
final playlistNamesById = {
|
|
for (final playlist in playlists) playlist.id: playlist.name,
|
|
};
|
|
final addedNames = <String>[];
|
|
|
|
for (final playlistId in idsToAdd) {
|
|
final playlistName = playlistNamesById[playlistId];
|
|
if (playlistName != null && playlistName.isNotEmpty) {
|
|
addedNames.add(playlistName);
|
|
}
|
|
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop();
|
|
|
|
if (addedNames.isNotEmpty) {
|
|
final name = addedNames.length == 1
|
|
? addedNames.first
|
|
: addedNames.join(', ');
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.collectionAddedToPlaylist(name))),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final playlistSummariesValue = ref.watch(
|
|
libraryPlaylistPickerSummariesProvider(_summaryRequest),
|
|
);
|
|
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
|
|
|
final String subtitle;
|
|
if (widget.tracks.length == 1) {
|
|
final track = widget.tracks.first;
|
|
subtitle = '${track.name} • ${track.artistName}';
|
|
} else {
|
|
subtitle = context.l10n.tracksCount(widget.tracks.length);
|
|
}
|
|
|
|
final resolvedPlaylists = playlistSummariesValue.asData?.value ?? const [];
|
|
final effectiveDisabledIds = <String>{
|
|
..._committedPlaylistIds,
|
|
for (final playlist in resolvedPlaylists)
|
|
if (playlist.containsAllRequestedTracks) playlist.id,
|
|
};
|
|
final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds);
|
|
final hasNewSelections = idsToAdd.isNotEmpty;
|
|
|
|
void onDone() {
|
|
if (hasNewSelections) {
|
|
_handleDone(resolvedPlaylists);
|
|
} else {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
return SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (context.isMornye) ...[
|
|
const AppSheetHandle(),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.collectionAddToPlaylist,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
subtitle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
] else
|
|
ListTile(
|
|
leading: const Icon(Icons.playlist_add),
|
|
title: Text(context.l10n.collectionAddToPlaylist),
|
|
subtitle: Text(subtitle),
|
|
),
|
|
const Divider(height: 1),
|
|
_PlaylistPickerRow(
|
|
leading: Icon(
|
|
context.isMornye
|
|
? CupertinoIcons.add_circled
|
|
: Icons.add_circle_outline,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
title: Text(context.l10n.collectionCreatePlaylist),
|
|
onTap: () async {
|
|
final name = await _promptPlaylistName(
|
|
context,
|
|
widget.playlistNamePrefill,
|
|
);
|
|
if (name == null || name.trim().isEmpty || !context.mounted) {
|
|
return;
|
|
}
|
|
final playlistId = await notifier.createPlaylist(name.trim());
|
|
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_committedPlaylistIds.add(playlistId);
|
|
_selectedPlaylistIds.remove(playlistId);
|
|
});
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.collectionAddedToPlaylist(name.trim()),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
Flexible(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 320),
|
|
child: playlistSummariesValue.when(
|
|
data: (playlists) {
|
|
if (playlists.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
|
child: Text(
|
|
context.l10n.collectionNoPlaylistsYet,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return ListView.builder(
|
|
itemCount: playlists.length,
|
|
itemBuilder: (context, index) {
|
|
final playlist = playlists[index];
|
|
final isAlreadyIn = effectiveDisabledIds.contains(
|
|
playlist.id,
|
|
);
|
|
final isSelected =
|
|
_selectedPlaylistIds.contains(playlist.id) ||
|
|
isAlreadyIn;
|
|
|
|
return _PlaylistPickerRow(
|
|
leading: _PlaylistPickerThumbnail(
|
|
playlist: playlist,
|
|
isSelected: !context.isMornye && isSelected,
|
|
),
|
|
title: Text(playlist.name),
|
|
subtitle: Text(
|
|
context.l10n.collectionPlaylistTracks(
|
|
playlist.trackCount,
|
|
),
|
|
),
|
|
enabled: !isAlreadyIn,
|
|
selected: isSelected,
|
|
onTap: !isAlreadyIn
|
|
? () {
|
|
setState(() {
|
|
if (_selectedPlaylistIds.contains(
|
|
playlist.id,
|
|
)) {
|
|
_selectedPlaylistIds.remove(playlist.id);
|
|
} else {
|
|
_selectedPlaylistIds.add(playlist.id);
|
|
}
|
|
});
|
|
}
|
|
: null,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
loading: () => const Center(
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
error: (_, _) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
|
child: Text(
|
|
context.l10n.collectionNoPlaylistsYet,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: context.isMornye
|
|
? AppActionButton(
|
|
onPressed: onDone,
|
|
icon: const Icon(CupertinoIcons.checkmark),
|
|
label: Text(context.l10n.dialogDone),
|
|
)
|
|
: FilledButton(
|
|
onPressed: onDone,
|
|
child: Text(context.l10n.dialogDone),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<String?> _promptPlaylistName(
|
|
BuildContext context,
|
|
String? playlistNamePrefill,
|
|
) async {
|
|
final controller = TextEditingController(text: playlistNamePrefill);
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
final result = await showAppDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AppAlertDialog(
|
|
title: Text(dialogContext.l10n.collectionCreatePlaylist),
|
|
content: Form(
|
|
key: formKey,
|
|
child: TextFormField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
textInputAction: TextInputAction.done,
|
|
decoration: InputDecoration(
|
|
hintText: dialogContext.l10n.collectionPlaylistNameHint,
|
|
),
|
|
validator: (value) {
|
|
final trimmed = value?.trim() ?? '';
|
|
if (trimmed.isEmpty) {
|
|
return dialogContext.l10n.collectionPlaylistNameRequired;
|
|
}
|
|
return null;
|
|
},
|
|
onFieldSubmitted: (_) {
|
|
if (formKey.currentState?.validate() != true) return;
|
|
Navigator.of(dialogContext).pop(controller.text.trim());
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
AppDialogAction(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: Text(dialogContext.l10n.dialogCancel),
|
|
),
|
|
AppDialogAction(
|
|
filled: true,
|
|
isDefault: true,
|
|
onPressed: () {
|
|
if (formKey.currentState?.validate() != true) return;
|
|
Navigator.of(dialogContext).pop(controller.text.trim());
|
|
},
|
|
child: Text(dialogContext.l10n.actionCreate),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
class _PlaylistPickerRow extends StatelessWidget {
|
|
const _PlaylistPickerRow({
|
|
required this.leading,
|
|
required this.title,
|
|
this.subtitle,
|
|
this.enabled = true,
|
|
this.selected,
|
|
this.onTap,
|
|
});
|
|
|
|
final Widget leading;
|
|
final Widget title;
|
|
final Widget? subtitle;
|
|
final bool enabled;
|
|
final bool? selected;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!context.isMornye) {
|
|
return ListTile(
|
|
leading: leading,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
enabled: enabled,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
final theme = Theme.of(context);
|
|
return Semantics(
|
|
selected: selected,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CupertinoButton(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
|
onPressed: enabled ? onTap : null,
|
|
child: Opacity(
|
|
opacity: enabled ? 1 : 0.5,
|
|
child: Row(
|
|
children: [
|
|
leading,
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DefaultTextStyle(
|
|
style: theme.textTheme.bodyLarge!,
|
|
child: title,
|
|
),
|
|
if (subtitle != null) ...[
|
|
const SizedBox(height: 3),
|
|
DefaultTextStyle(
|
|
style: theme.textTheme.bodyMedium!.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
child: subtitle!,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (selected != null) ...[
|
|
const SizedBox(width: 14),
|
|
Icon(
|
|
selected!
|
|
? CupertinoIcons.checkmark_circle_fill
|
|
: CupertinoIcons.circle,
|
|
size: 24,
|
|
color: selected!
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 0.5, thickness: 0.5, indent: 20, endIndent: 20),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlaylistPickerThumbnail extends StatelessWidget {
|
|
final PlaylistPickerSummary playlist;
|
|
final bool isSelected;
|
|
|
|
const _PlaylistPickerThumbnail({
|
|
required this.playlist,
|
|
required this.isSelected,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
const double size = 48;
|
|
final borderRadius = BorderRadius.circular(8);
|
|
|
|
return SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: borderRadius,
|
|
child: _buildCoverImage(context, colorScheme, size),
|
|
),
|
|
if (isSelected) ...[
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary.withValues(alpha: 0.3),
|
|
borderRadius: borderRadius,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 2,
|
|
top: 2,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: colorScheme.primary, width: 1.5),
|
|
),
|
|
child: Icon(
|
|
Icons.check,
|
|
color: colorScheme.onPrimary,
|
|
size: 14,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCoverImage(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
double size,
|
|
) {
|
|
final customCoverPath = playlist.coverImagePath;
|
|
if (customCoverPath != null && customCoverPath.isNotEmpty) {
|
|
return Image.file(
|
|
File(customCoverPath),
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).round(),
|
|
cacheHeight: (size * MediaQuery.devicePixelRatioOf(context)).round(),
|
|
filterQuality: FilterQuality.low,
|
|
errorBuilder: (_, _, _) => _iconFallback(context, colorScheme, size),
|
|
);
|
|
}
|
|
|
|
final firstCoverUrl = playlist.previewCover;
|
|
if (firstCoverUrl != null) {
|
|
return LocalOrNetworkCoverImage(
|
|
url: firstCoverUrl,
|
|
width: size,
|
|
height: size,
|
|
networkCacheWidth: (size * 2).toInt(),
|
|
placeholder: (_) => _iconFallback(context, colorScheme, size),
|
|
);
|
|
}
|
|
|
|
return _iconFallback(context, colorScheme, size);
|
|
}
|
|
|
|
Widget _iconFallback(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
double size,
|
|
) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
context.isMornye ? CupertinoIcons.music_note_list : Icons.queue_music,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
}
|
|
}
|