mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-25 11:00:42 +02:00
refactor(ui): consolidate duplicated sheets, rows, and cover widgets
- single-track convert sheet now reuses BatchConvertSheet (confirmLabelBuilder + sourceIsLossless params) instead of a full inline copy - provider priority page migrated to PrioritySettingsScaffold; shared showDiscardChangesDialog and ReorderablePriorityItem replace per-page copies - home tab search rows unified into one _SearchResultRowItem; _parseTrack copies rebuilt on Track.fromBackendMap with only the load-bearing local overrides kept; loading/error scaffold extracted - PlayerArtwork widget shared by now-playing and mini player - LocalOrNetworkCoverImage dispatches file vs network cover in one place (playlist picker, queue nav, library folder)
This commit is contained in:
@@ -9,7 +9,15 @@ class BatchConvertSheet extends StatefulWidget {
|
||||
final List<String> formats;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String confirmLabel;
|
||||
final String? confirmLabel;
|
||||
final String Function(
|
||||
String format,
|
||||
String bitrate,
|
||||
bool isLosslessTarget,
|
||||
LosslessConversionQuality losslessQuality,
|
||||
)?
|
||||
confirmLabelBuilder;
|
||||
final bool sourceIsLossless;
|
||||
final int? sourceBitDepth;
|
||||
final int? sourceSampleRate;
|
||||
final void Function(
|
||||
@@ -24,8 +32,10 @@ class BatchConvertSheet extends StatefulWidget {
|
||||
super.key,
|
||||
required this.formats,
|
||||
required this.title,
|
||||
required this.confirmLabel,
|
||||
required this.onConvert,
|
||||
this.confirmLabel,
|
||||
this.confirmLabelBuilder,
|
||||
this.sourceIsLossless = true,
|
||||
this.subtitle,
|
||||
this.sourceBitDepth,
|
||||
this.sourceSampleRate,
|
||||
@@ -317,7 +327,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLosslessTarget)
|
||||
if (_isLosslessTarget && widget.sourceIsLossless)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
@@ -383,7 +393,18 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
label: Text(widget.confirmLabel),
|
||||
label: Text(
|
||||
widget.confirmLabelBuilder?.call(
|
||||
_selectedFormat,
|
||||
_selectedBitrate,
|
||||
_isLosslessTarget,
|
||||
LosslessConversionQuality(
|
||||
maxBitDepth: _selectedMaxBitDepth,
|
||||
maxSampleRate: _selectedMaxSampleRate,
|
||||
),
|
||||
) ??
|
||||
widget.confirmLabel!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
@@ -83,6 +85,94 @@ class CachedCoverImage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders [url] as a local file (when it's not an http/https URL) or as a
|
||||
/// cached network image otherwise, with a shared [placeholder] used for the
|
||||
/// local error state, the local not-ready frame, and the network
|
||||
/// placeholder/error states alike.
|
||||
class LocalOrNetworkCoverImage extends StatelessWidget {
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final BorderRadius? borderRadius;
|
||||
final int? localCacheWidth;
|
||||
final int? networkCacheWidth;
|
||||
final Duration? fadeInDuration;
|
||||
final Duration fadeOutDuration;
|
||||
final String Function(String)? urlTransform;
|
||||
final Widget Function(BuildContext) placeholder;
|
||||
|
||||
const LocalOrNetworkCoverImage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.borderRadius,
|
||||
this.localCacheWidth,
|
||||
this.networkCacheWidth,
|
||||
this.fadeInDuration,
|
||||
this.fadeOutDuration = Duration.zero,
|
||||
this.urlTransform,
|
||||
required this.placeholder,
|
||||
});
|
||||
|
||||
bool get _isLocal =>
|
||||
!url.startsWith('http://') && !url.startsWith('https://');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLocal) {
|
||||
final image = Image.file(
|
||||
File(url),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
cacheWidth: localCacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: fadeInDuration == null
|
||||
? null
|
||||
: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
final ready = wasSynchronouslyLoaded || frame != null;
|
||||
if (fadeInDuration == Duration.zero) {
|
||||
return ready ? child : placeholder(context);
|
||||
}
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
placeholder(context),
|
||||
AnimatedOpacity(
|
||||
opacity: ready ? 1.0 : 0.0,
|
||||
duration: fadeInDuration!,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder(context),
|
||||
);
|
||||
return borderRadius == null
|
||||
? image
|
||||
: ClipRRect(borderRadius: borderRadius!, child: image);
|
||||
}
|
||||
|
||||
return CachedCoverImage(
|
||||
imageUrl: urlTransform?.call(url) ?? url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
memCacheWidth: networkCacheWidth,
|
||||
borderRadius: borderRadius,
|
||||
fadeInDuration: fadeInDuration ?? Duration.zero,
|
||||
fadeOutDuration: fadeOutDuration,
|
||||
placeholder: (_, _) => placeholder(context),
|
||||
errorWidget: (_, _, _) => placeholder(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider cachedCoverImageProvider(String url) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
/// Shared discard-unsaved-changes confirmation dialog used by priority /
|
||||
/// selection settings pages.
|
||||
Future<bool> showDiscardChangesDialog(
|
||||
BuildContext context, {
|
||||
String? content,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(content ?? context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/screens/now_playing_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class MiniPlayer extends ConsumerWidget {
|
||||
@@ -62,9 +59,11 @@ class MiniPlayer extends ConsumerWidget {
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: _MiniArt(
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
cacheWidth: 132,
|
||||
iconSize: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -109,45 +108,3 @@ class MiniPlayer extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniArt extends StatelessWidget {
|
||||
final String? artUri;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _MiniArt({required this.artUri, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeholder = Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 22,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
final uri = artUri;
|
||||
if (uri == null || uri.isEmpty) return placeholder;
|
||||
if (uri.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
memCacheWidth: 132,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeOutDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
if (uri.startsWith('file://')) {
|
||||
return Image.file(
|
||||
File(Uri.parse(uri).toFilePath()),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 132,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
class PlayerArtwork extends StatelessWidget {
|
||||
final String? artUri;
|
||||
final ColorScheme colorScheme;
|
||||
final int? cacheWidth;
|
||||
final double iconSize;
|
||||
|
||||
const PlayerArtwork({
|
||||
super.key,
|
||||
required this.artUri,
|
||||
required this.colorScheme,
|
||||
this.cacheWidth,
|
||||
this.iconSize = 40,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeholder = Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
final uri = artUri;
|
||||
if (uri == null || uri.isEmpty) return placeholder;
|
||||
|
||||
if (uri.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
memCacheWidth: cacheWidth,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeOutDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
if (uri.startsWith('file://')) {
|
||||
final path = Uri.parse(uri).toFilePath();
|
||||
return Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.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/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
Future<void> showAddTrackToPlaylistSheet(
|
||||
BuildContext context,
|
||||
@@ -383,29 +382,12 @@ class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
|
||||
final firstCoverUrl = playlist.previewCover;
|
||||
if (firstCoverUrl != null) {
|
||||
final isLocalPath =
|
||||
!firstCoverUrl.startsWith('http://') &&
|
||||
!firstCoverUrl.startsWith('https://');
|
||||
|
||||
if (isLocalPath) {
|
||||
return Image.file(
|
||||
File(firstCoverUrl),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
return LocalOrNetworkCoverImage(
|
||||
url: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (size * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => _iconFallback(colorScheme, size),
|
||||
errorWidget: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
networkCacheWidth: (size * 2).toInt(),
|
||||
placeholder: (_) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared numbered, draggable row used by provider-priority reorder lists.
|
||||
class ReorderablePriorityItem extends StatelessWidget {
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String name;
|
||||
final String subtitle;
|
||||
final Widget? trailing;
|
||||
|
||||
const ReorderablePriorityItem({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.name,
|
||||
required this.subtitle,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(icon, color: iconColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[trailing!, const SizedBox(width: 4)],
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user