diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 688c9b65..96b70d37 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -517,15 +517,23 @@ class _CollectionItemWidget extends StatelessWidget { } } -/// Widget for displaying artist items from default search (Deezer/Spotify) -class _SearchArtistItemWidget extends StatelessWidget { - final SearchArtist artist; +/// Generic row for artist/album/playlist results from default search +/// (Deezer/Spotify); cover shape, fallback icon, and subtitle vary per type. +class _SearchResultRowItem extends StatelessWidget { + final String? imageUrl; + final double coverBorderRadius; + final IconData fallbackIcon; + final String title; + final Widget subtitle; final bool showDivider; final VoidCallback onTap; - const _SearchArtistItemWidget({ - super.key, - required this.artist, + const _SearchResultRowItem({ + required this.imageUrl, + required this.coverBorderRadius, + required this.fallbackIcon, + required this.title, + required this.subtitle, required this.showDivider, required this.onTap, }); @@ -534,9 +542,9 @@ class _SearchArtistItemWidget extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final hasValidImage = - artist.imageUrl != null && - artist.imageUrl!.isNotEmpty && - Uri.tryParse(artist.imageUrl!)?.hasAuthority == true; + imageUrl != null && + imageUrl!.isNotEmpty && + Uri.tryParse(imageUrl!)?.hasAuthority == true; return Column( mainAxisSize: MainAxisSize.min, @@ -550,10 +558,10 @@ class _SearchArtistItemWidget extends StatelessWidget { child: Row( children: [ ClipRRect( - borderRadius: BorderRadius.circular(28), + borderRadius: BorderRadius.circular(coverBorderRadius), child: hasValidImage ? CachedCoverImage( - imageUrl: artist.imageUrl!, + imageUrl: imageUrl!, width: 56, height: 56, fit: BoxFit.cover, @@ -563,7 +571,7 @@ class _SearchArtistItemWidget extends StatelessWidget { height: 56, color: colorScheme.surfaceContainerHighest, child: Icon( - Icons.person, + fallbackIcon, color: colorScheme.onSurfaceVariant, ), ), @@ -574,7 +582,7 @@ class _SearchArtistItemWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - artist.name, + title, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), @@ -582,14 +590,7 @@ class _SearchArtistItemWidget extends StatelessWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - context.l10n.recentTypeArtist, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + subtitle, ], ), ), @@ -615,6 +616,41 @@ class _SearchArtistItemWidget extends StatelessWidget { } } +/// Widget for displaying artist items from default search (Deezer/Spotify) +class _SearchArtistItemWidget extends StatelessWidget { + final SearchArtist artist; + final bool showDivider; + final VoidCallback onTap; + + const _SearchArtistItemWidget({ + super.key, + required this.artist, + required this.showDivider, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return _SearchResultRowItem( + imageUrl: artist.imageUrl, + coverBorderRadius: 28, + fallbackIcon: Icons.person, + title: artist.name, + subtitle: Text( + context.l10n.recentTypeArtist, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + showDivider: showDivider, + onTap: onTap, + ); + } +} + /// Widget for displaying album items from default search (Deezer/Spotify) class _SearchAlbumItemWidget extends StatelessWidget { final SearchAlbum album; @@ -631,87 +667,24 @@ class _SearchAlbumItemWidget extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final hasValidImage = - album.imageUrl != null && - album.imageUrl!.isNotEmpty && - Uri.tryParse(album.imageUrl!)?.hasAuthority == true; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: hasValidImage - ? CachedCoverImage( - imageUrl: album.imageUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - album.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: album.artists.isNotEmpty - ? album.artists - : context.l10n.recentTypeAlbum, - coverUrl: album.imageUrl, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 80, - endIndent: 12, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], + return _SearchResultRowItem( + imageUrl: album.imageUrl, + coverBorderRadius: 10, + fallbackIcon: Icons.album, + title: album.name, + subtitle: ClickableArtistName( + artistName: album.artists.isNotEmpty + ? album.artists + : context.l10n.recentTypeAlbum, + coverUrl: album.imageUrl, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + showDivider: showDivider, + onTap: onTap, ); } } @@ -732,86 +705,23 @@ class _SearchPlaylistItemWidget extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final hasValidImage = - playlist.imageUrl != null && - playlist.imageUrl!.isNotEmpty && - Uri.tryParse(playlist.imageUrl!)?.hasAuthority == true; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: hasValidImage - ? CachedCoverImage( - imageUrl: playlist.imageUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.playlist_play, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - playlist.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - playlist.owner.isNotEmpty - ? playlist.owner - : context.l10n.recentTypePlaylist, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 80, - endIndent: 12, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], + return _SearchResultRowItem( + imageUrl: playlist.imageUrl, + coverBorderRadius: 10, + fallbackIcon: Icons.playlist_play, + title: playlist.name, + subtitle: Text( + playlist.owner.isNotEmpty + ? playlist.owner + : context.l10n.recentTypePlaylist, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + showDivider: showDivider, + onTap: onTap, ); } } @@ -935,6 +845,71 @@ class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> { } } +/// Legacy `duration_ms` parsing used by extension album/playlist/artist track +/// parsing, kept separate from [extractDurationMs] which also falls back to +/// a `duration` (seconds) field these call sites intentionally ignore. +int _legacyTrackDurationMs(Map data) { + final durationValue = data['duration_ms']; + if (durationValue is int) return durationValue; + if (durationValue is double) return durationValue.toInt(); + return 0; +} + +/// Prefers the track's own cover, falling back to the parent +/// album/playlist cover, without URL validation. +String? _resolveTrackCoverUrl(String? trackCover, String? fallbackCover) { + if (trackCover != null && trackCover.isNotEmpty) return trackCover; + return fallbackCover; +} + +/// Shared loading/error+retry scaffold for extension album/playlist/artist +/// detail screens. +class _LoadingOrErrorScaffold extends StatelessWidget { + final String title; + final bool isLoading; + final String? error; + final Widget loadingBody; + final VoidCallback onRetry; + + const _LoadingOrErrorScaffold({ + required this.title, + required this.isLoading, + required this.error, + required this.loadingBody, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: loadingBody, + ); + } + + return Scaffold( + appBar: AppBar(title: Text(title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: onRetry, + child: Text(context.l10n.dialogRetry), + ), + ], + ), + ), + ); + } +} + class ExtensionAlbumScreen extends ConsumerStatefulWidget { final String extensionId; final String albumId; @@ -1059,86 +1034,48 @@ class _ExtensionAlbumScreenState extends ConsumerState { String? albumTypeFallback, int? totalTracksFallback, }) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } - + final base = Track.fromBackendMap(data, source: widget.extensionId); return Track( id: (data['id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + name: base.name, + artistName: base.artistName, albumName: (data['album_name'] ?? widget.albumName).toString(), albumArtist: normalizeOptionalString(data['album_artist']?.toString()), - artistId: - (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, - albumId: data['album_id']?.toString() ?? widget.albumId, - coverUrl: _resolveCoverUrl( + artistId: base.artistId ?? _artistId, + albumId: base.albumId ?? widget.albumId, + coverUrl: _resolveTrackCoverUrl( data['cover_url']?.toString(), widget.coverUrl, ), - isrc: data['isrc']?.toString(), - duration: (durationMs / 1000).round(), - trackNumber: data['track_number'] as int?, - discNumber: data['disc_number'] as int?, - totalDiscs: data['total_discs'] as int?, - releaseDate: data['release_date']?.toString(), - albumType: - normalizeOptionalString(data['album_type']?.toString()) ?? - albumTypeFallback ?? - _albumType, - totalTracks: - data['total_tracks'] as int? ?? - totalTracksFallback ?? - _albumTotalTracks, - composer: data['composer']?.toString(), - source: widget.extensionId, - audioQuality: data['audio_quality']?.toString(), - audioModes: data['audio_modes']?.toString(), - previewUrl: data['preview_url']?.toString(), - explicit: parseExplicitFlag(data['explicit']), + isrc: base.isrc, + duration: (_legacyTrackDurationMs(data) / 1000).round(), + trackNumber: base.trackNumber, + discNumber: base.discNumber, + totalDiscs: base.totalDiscs, + releaseDate: base.releaseDate, + albumType: base.albumType ?? albumTypeFallback ?? _albumType, + totalTracks: base.totalTracks ?? totalTracksFallback ?? _albumTotalTracks, + composer: base.composer, + source: base.source, + audioQuality: base.audioQuality, + audioModes: base.audioModes, + previewUrl: base.previewUrl, + explicit: base.explicit, ); } - String? _resolveCoverUrl(String? trackCover, String? albumCover) { - if (trackCover != null && trackCover.isNotEmpty) return trackCover; - return albumCover; - } - @override Widget build(BuildContext context) { - if (_isLoading) { - return Scaffold( - appBar: AppBar(title: Text(widget.albumName)), - body: const AlbumTrackListSkeleton( + if (_isLoading || _error != null) { + return _LoadingOrErrorScaffold( + title: widget.albumName, + isLoading: _isLoading, + error: _error, + loadingBody: const AlbumTrackListSkeleton( itemCount: 10, showCoverHeader: true, ), - ); - } - - if (_error != null) { - return Scaffold( - appBar: AppBar(title: Text(widget.albumName)), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _fetchTracks, - child: Text(context.l10n.dialogRetry), - ), - ], - ), - ), + onRetry: _fetchTracks, ); } @@ -1239,74 +1176,46 @@ class _ExtensionPlaylistScreenState } Track _parseTrack(Map data) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } - + final base = Track.fromBackendMap(data, source: widget.extensionId); return Track( id: (data['id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + name: base.name, + artistName: base.artistName, albumName: (data['album_name'] ?? '').toString(), - artistId: (data['artist_id'] ?? data['artistId'])?.toString(), - albumId: data['album_id']?.toString(), - coverUrl: _resolveCoverUrl( + artistId: base.artistId, + albumId: base.albumId, + coverUrl: _resolveTrackCoverUrl( data['cover_url']?.toString(), widget.coverUrl, ), - isrc: data['isrc']?.toString(), - duration: (durationMs / 1000).round(), - trackNumber: data['track_number'] as int?, - discNumber: data['disc_number'] as int?, - totalDiscs: data['total_discs'] as int?, - releaseDate: data['release_date']?.toString(), - totalTracks: data['total_tracks'] as int?, - composer: data['composer']?.toString(), - source: widget.extensionId, - audioQuality: data['audio_quality']?.toString(), - audioModes: data['audio_modes']?.toString(), - previewUrl: data['preview_url']?.toString(), - explicit: parseExplicitFlag(data['explicit']), + isrc: base.isrc, + duration: (_legacyTrackDurationMs(data) / 1000).round(), + trackNumber: base.trackNumber, + discNumber: base.discNumber, + totalDiscs: base.totalDiscs, + releaseDate: base.releaseDate, + totalTracks: base.totalTracks, + composer: base.composer, + source: base.source, + audioQuality: base.audioQuality, + audioModes: base.audioModes, + previewUrl: base.previewUrl, + explicit: base.explicit, ); } - String? _resolveCoverUrl(String? trackCover, String? playlistCover) { - if (trackCover != null && trackCover.isNotEmpty) return trackCover; - return playlistCover; - } - @override Widget build(BuildContext context) { - if (_isLoading) { - return Scaffold( - appBar: AppBar(title: Text(widget.playlistName)), - body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true), - ); - } - - if (_error != null) { - return Scaffold( - appBar: AppBar(title: Text(widget.playlistName)), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _fetchTracks, - child: Text(context.l10n.dialogRetry), - ), - ], - ), + if (_isLoading || _error != null) { + return _LoadingOrErrorScaffold( + title: widget.playlistName, + isLoading: _isLoading, + error: _error, + loadingBody: const TrackListSkeleton( + itemCount: 8, + showCoverHeader: true, ), + onRetry: _fetchTracks, ); } @@ -1427,68 +1336,38 @@ class _ExtensionArtistScreenState extends ConsumerState { } Track _parseTrack(Map data) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } - + final base = Track.fromBackendMap(data); return Track( id: (data['id'] ?? data['spotify_id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artistName: (data['artists'] ?? data['artist'] ?? '').toString(), - albumName: (data['album_name'] ?? data['album'] ?? '').toString(), - albumArtist: data['album_artist']?.toString(), - artistId: - (data['artist_id'] ?? data['artistId'])?.toString() ?? - widget.artistId, - albumId: data['album_id']?.toString(), - coverUrl: normalizeCoverReference( - (data['cover_url'] ?? data['images'])?.toString(), - ), - isrc: data['isrc']?.toString(), - duration: (durationMs / 1000).round(), - trackNumber: data['track_number'] as int?, - discNumber: data['disc_number'] as int?, - totalDiscs: data['total_discs'] as int?, - releaseDate: data['release_date']?.toString(), - totalTracks: data['total_tracks'] as int?, - composer: data['composer']?.toString(), + name: base.name, + artistName: base.artistName, + albumName: base.albumName, + albumArtist: base.albumArtist, + artistId: base.artistId ?? widget.artistId, + albumId: base.albumId, + coverUrl: base.coverUrl, + isrc: base.isrc, + duration: (_legacyTrackDurationMs(data) / 1000).round(), + trackNumber: base.trackNumber, + discNumber: base.discNumber, + totalDiscs: base.totalDiscs, + releaseDate: base.releaseDate, + totalTracks: base.totalTracks, + composer: base.composer, source: (data['provider_id'] ?? widget.extensionId).toString(), - previewUrl: data['preview_url']?.toString(), + previewUrl: base.previewUrl, ); } @override Widget build(BuildContext context) { - if (_isLoading) { - return Scaffold( - appBar: AppBar(title: Text(widget.artistName)), - body: const ArtistScreenSkeleton(), - ); - } - - if (_error != null) { - return Scaffold( - appBar: AppBar(title: Text(widget.artistName)), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _fetchArtist, - child: Text(context.l10n.dialogRetry), - ), - ], - ), - ), + if (_isLoading || _error != null) { + return _LoadingOrErrorScaffold( + title: widget.artistName, + isLoading: _isLoading, + error: _error, + loadingBody: const ArtistScreenSkeleton(), + onRetry: _fetchArtist, ); } diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 0e9a1b8b..fdba9840 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -1,9 +1,9 @@ import 'dart:io'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -14,7 +14,6 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -105,10 +104,6 @@ class _LibraryTracksFolderScreenState return null; } - bool _isCoverLocalPath(String url) { - return !url.startsWith('http://') && !url.startsWith('https://'); - } - void _enterSelectionMode(String key) { HapticFeedback.mediumImpact(); setState(() { @@ -555,31 +550,15 @@ class _LibraryTracksFolderScreenState errorBuilder: (_, _, _) => coverFallback, ) : hasCoverUrl - ? _isCoverLocalPath(coverUrl) - ? Image.file( - File(coverUrl), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return Container(color: colorScheme.surface); - }, - errorBuilder: (_, _, _) => - Container(color: colorScheme.surface), - ) - : CachedNetworkImage( - imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => Container(color: colorScheme.surface), - errorWidget: (_, _, _) => - Container(color: colorScheme.surface), - ) + ? LocalOrNetworkCoverImage( + url: coverUrl, + fit: BoxFit.cover, + localCacheWidth: cacheWidth, + networkCacheWidth: cacheWidth, + fadeInDuration: Duration.zero, + urlTransform: (u) => highResCoverUrl(u) ?? u, + placeholder: (_) => Container(color: colorScheme.surface), + ) : coverFallback; return AlbumDetailHeader( @@ -603,27 +582,16 @@ class _LibraryTracksFolderScreenState errorBuilder: (_, _, _) => squarePlaceholder(), ); } - if (hasCoverUrl && _isCoverLocalPath(coverUrl)) { - return Image.file( - File(coverUrl), - fit: BoxFit.cover, - width: coverSize, - height: coverSize, - cacheWidth: cacheWidth, - gaplessPlayback: true, - errorBuilder: (_, _, _) => squarePlaceholder(), - ); - } if (hasCoverUrl) { - return CachedNetworkImage( - imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, + return LocalOrNetworkCoverImage( + url: coverUrl, fit: BoxFit.cover, width: coverSize, height: coverSize, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => squarePlaceholder(), - errorWidget: (_, _, _) => squarePlaceholder(), + localCacheWidth: cacheWidth, + networkCacheWidth: cacheWidth, + urlTransform: (u) => highResCoverUrl(u) ?? u, + placeholder: (_) => squarePlaceholder(), ); } return squarePlaceholder(); @@ -1229,8 +1197,6 @@ class _CollectionTrackTile extends ConsumerWidget { } Widget _buildTrackCover(BuildContext context, String coverUrl, double size) { - final isLocal = - !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://'); final colorScheme = Theme.of(context).colorScheme; Widget placeholder() => Container( width: size, @@ -1239,47 +1205,14 @@ class _CollectionTrackTile extends ConsumerWidget { child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), ); - if (isLocal) { - return Image.file( - File(coverUrl), - width: size, - height: size, - fit: BoxFit.cover, - gaplessPlayback: true, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded) return child; - return SizedBox( - width: size, - height: size, - child: Stack( - fit: StackFit.expand, - children: [ - placeholder(), - AnimatedOpacity( - opacity: frame == null ? 0.0 : 1.0, - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - child: child, - ), - ], - ), - ); - }, - errorBuilder: (_, _, _) => placeholder(), - ); - } - - return CachedNetworkImage( - imageUrl: coverUrl, + return LocalOrNetworkCoverImage( + url: coverUrl, width: size, height: size, - fit: BoxFit.cover, - memCacheWidth: (size * 2).toInt(), - cacheManager: CoverCacheManager.instance, + networkCacheWidth: (size * 2).toInt(), fadeInDuration: const Duration(milliseconds: 180), fadeOutDuration: const Duration(milliseconds: 90), - placeholder: (_, _) => placeholder(), - errorWidget: (_, _, _) => placeholder(), + placeholder: (_) => placeholder(), ); } diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 760da535..12b3a042 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:audio_service/audio_service.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,11 +8,11 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; final _log = AppLogger('NowPlaying'); @@ -259,7 +257,7 @@ class _NowPlayingScreenState extends ConsumerState { child: SizedBox( width: artSize, height: artSize, - child: _Artwork( + child: PlayerArtwork( artUri: mediaItem.artUri?.toString(), colorScheme: colorScheme, cacheWidth: @@ -1077,53 +1075,3 @@ class _QualityBadge extends StatelessWidget { ); } } - -class _Artwork extends StatelessWidget { - final String? artUri; - final ColorScheme colorScheme; - final int? cacheWidth; - - const _Artwork({ - required this.artUri, - required this.colorScheme, - this.cacheWidth, - }); - - @override - Widget build(BuildContext context) { - final placeholder = Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 40, - 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; - } -} diff --git a/lib/screens/queue_tab_navigation.dart b/lib/screens/queue_tab_navigation.dart index d298e3ca..dcd56c65 100644 --- a/lib/screens/queue_tab_navigation.dart +++ b/lib/screens/queue_tab_navigation.dart @@ -424,36 +424,15 @@ extension _QueueTabNavigation on _QueueTabState { if (firstCoverUrl != null) { // Guard against local file paths that may have been stored as coverUrl - final isLocalPath = - !firstCoverUrl.startsWith('http://') && - !firstCoverUrl.startsWith('https://'); - if (isLocalPath) { - return ClipRRect( - borderRadius: borderRadius, - child: Image.file( - File(firstCoverUrl), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: cacheExtent, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; - return placeholder; - }, - errorBuilder: (_, _, _) => placeholder, - ), - ); - } - return CachedCoverImage( - imageUrl: firstCoverUrl, + return LocalOrNetworkCoverImage( + url: firstCoverUrl, width: size, height: size, - memCacheWidth: cacheExtent, borderRadius: borderRadius, - placeholder: (_, _) => placeholder, - errorWidget: (_, _, _) => placeholder, + localCacheWidth: cacheExtent, + networkCacheWidth: cacheExtent, + fadeInDuration: Duration.zero, + placeholder: (_) => placeholder, ); } diff --git a/lib/screens/settings/download_fallback_extensions_page.dart b/lib/screens/settings/download_fallback_extensions_page.dart index 67c7d9a2..07599084 100644 --- a/lib/screens/settings/download_fallback_extensions_page.dart +++ b/lib/screens/settings/download_fallback_extensions_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/discard_changes_dialog.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; @@ -57,7 +58,7 @@ class _DownloadFallbackExtensionsPageState canPop: !_hasChanges, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; - final shouldPop = await _confirmDiscard(context); + final shouldPop = await showDiscardChangesDialog(context); if (shouldPop && context.mounted) { Navigator.pop(context); } @@ -72,7 +73,7 @@ class _DownloadFallbackExtensionsPageState icon: const Icon(Icons.arrow_back), onPressed: () async { if (_hasChanges) { - final shouldPop = await _confirmDiscard(context); + final shouldPop = await showDiscardChangesDialog(context); if (shouldPop && context.mounted) { Navigator.pop(context); } @@ -172,27 +173,6 @@ class _DownloadFallbackExtensionsPageState ); } - Future _confirmDiscard(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.dialogDiscardChanges), - content: Text(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; - } - void _saveChanges() { final allExtensionIds = _extensions .map((extension) => extension.id) diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index 2137b004..b662ff40 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/discard_changes_dialog.dart'; import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; +import 'package:spotiflac_android/widgets/reorderable_priority_item.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/constants/music_services.dart'; @@ -66,7 +68,10 @@ class _LyricsProviderPriorityPageState description: context.l10n.lyricsProvidersDescription, infoText: context.l10n.lyricsProvidersInfoText, onSave: _saveChanges, - onConfirmDiscard: _confirmDiscard, + onConfirmDiscard: (ctx) => showDiscardChangesDialog( + ctx, + content: ctx.l10n.lyricsProvidersDiscardContent, + ), slivers: [ if (_enabledProviders.isNotEmpty) SliverToBoxAdapter( @@ -84,13 +89,23 @@ class _LyricsProviderPriorityPageState itemBuilder: (context, index) { final id = _enabledProviders[index]; final info = _getLyricsProviderInfo(id, context); - return _EnabledProviderItem( + return ReorderablePriorityItem( key: ValueKey(id), - providerId: id, - info: info, index: index, isFirst: index == 0, - onToggle: () => _disableProvider(id), + icon: info.icon, + iconColor: Theme.of(context).colorScheme.primary, + name: info.name, + subtitle: info.description, + trailing: SizedBox( + height: 32, + child: FittedBox( + child: Switch( + value: true, + onChanged: (_) => _disableProvider(id), + ), + ), + ), ); }, onReorderItem: (oldIndex, newIndex) { @@ -160,27 +175,6 @@ class _LyricsProviderPriorityPageState ).showSnackBar(SnackBar(content: Text(context.l10n.lyricsProvidersSaved))); } - Future _confirmDiscard(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.dialogDiscardChanges), - content: Text(context.l10n.lyricsProvidersDiscardContent), - 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; - } - static _LyricsProviderInfo _getLyricsProviderInfo( String id, BuildContext context, @@ -262,105 +256,6 @@ class _LyricsProviderPriorityPageState } } -class _EnabledProviderItem extends StatelessWidget { - final String providerId; - final _LyricsProviderInfo info; - final int index; - final bool isFirst; - final VoidCallback onToggle; - - const _EnabledProviderItem({ - super.key, - required this.providerId, - required this.info, - required this.index, - required this.isFirst, - required this.onToggle, - }); - - @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(info.icon, color: colorScheme.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - info.name, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - Text( - info.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - SizedBox( - height: 32, - child: FittedBox( - child: Switch(value: true, onChanged: (_) => onToggle()), - ), - ), - const SizedBox(width: 4), - Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), - ], - ), - ), - ), - ), - ); - } -} - class _DisabledProviderItem extends StatelessWidget { final String providerId; final _LyricsProviderInfo info; diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart index 5e81580f..1ff26822 100644 --- a/lib/screens/settings/metadata_provider_priority_page.dart +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/widgets/discard_changes_dialog.dart'; import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; +import 'package:spotiflac_android/widgets/reorderable_priority_item.dart'; class MetadataProviderPriorityPage extends ConsumerStatefulWidget { const MetadataProviderPriorityPage({super.key}); @@ -52,7 +54,7 @@ class _MetadataProviderPriorityPageState infoText: context.l10n.metadataProviderPriorityInfo, saveLabel: context.l10n.dialogSave, onSave: _saveChanges, - onConfirmDiscard: _confirmDiscard, + onConfirmDiscard: showDiscardChangesDialog, slivers: [ SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -60,17 +62,19 @@ class _MetadataProviderPriorityPageState itemCount: _providers.length, itemBuilder: (context, index) { final provider = _providers[index]; - return _MetadataProviderItem( + final extension = ref + .read(extensionProvider) + .extensions + .where((ext) => ext.id == provider) + .firstOrNull; + return ReorderablePriorityItem( key: ValueKey(provider), - provider: provider, index: index, isFirst: index == 0, - isLast: index == _providers.length - 1, - extension: ref - .read(extensionProvider) - .extensions - .where((ext) => ext.id == provider) - .firstOrNull, + icon: Icons.extension, + iconColor: Theme.of(context).colorScheme.secondary, + name: extension?.displayName ?? provider, + subtitle: context.l10n.providerExtension, ); }, onReorderItem: (oldIndex, newIndex) { @@ -86,27 +90,6 @@ class _MetadataProviderPriorityPageState ); } - Future _confirmDiscard(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.dialogDiscardChanges), - content: Text(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; - } - Future _saveChanges() async { await ref .read(extensionProvider.notifier) @@ -120,121 +103,3 @@ class _MetadataProviderPriorityPageState ); } } - -class _MetadataProviderItem extends StatelessWidget { - final String provider; - final int index; - final bool isFirst; - final bool isLast; - final Extension? extension; - - const _MetadataProviderItem({ - super.key, - required this.provider, - required this.index, - required this.isFirst, - required this.isLast, - this.extension, - }); - - @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; - - final info = _getProviderInfo(context, provider, extension); - - 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(info.icon, color: colorScheme.secondary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - info.name, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - Text( - info.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), - ], - ), - ), - ), - ), - ); - } - - _MetadataProviderInfo _getProviderInfo( - BuildContext context, - String provider, - Extension? extension, - ) { - return _MetadataProviderInfo( - name: extension?.displayName ?? provider, - icon: Icons.extension, - description: context.l10n.providerExtension, - ); - } -} - -class _MetadataProviderInfo { - final String name; - final IconData icon; - final String description; - - _MetadataProviderInfo({ - required this.name, - required this.icon, - required this.description, - }); -} diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index 864b7fa2..c9425974 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/discard_changes_dialog.dart'; +import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; +import 'package:spotiflac_android/widgets/reorderable_priority_item.dart'; class ProviderPriorityPage extends ConsumerStatefulWidget { const ProviderPriorityPage({super.key}); @@ -43,146 +45,49 @@ class _ProviderPriorityPageState extends ConsumerState { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return PopScope( - canPop: !_hasChanges, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - final shouldPop = await _confirmDiscard(context); - if (shouldPop && context.mounted) { - Navigator.pop(context); - } - }, - child: Scaffold( - body: CustomScrollView( - slivers: [ - SettingsSliverAppBar( - title: context.l10n.providerPriorityTitle, - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), - onPressed: () async { - if (_hasChanges) { - final shouldPop = await _confirmDiscard(context); - if (shouldPop && context.mounted) { - Navigator.pop(context); - } - } else { - Navigator.pop(context); - } - }, - ), - actions: [ - if (_hasChanges) - TextButton( - onPressed: _saveChanges, - child: Text(context.l10n.dialogSave), - ), - ], - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.providerPriorityDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ), - - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverReorderableList( - itemCount: _providers.length, - itemBuilder: (context, index) { - final provider = _providers[index]; - return _ProviderItem( - key: ValueKey(provider), - provider: provider, - index: index, - isFirst: index == 0, - isLast: index == _providers.length - 1, - extension: ref - .read(extensionProvider) - .extensions - .where((ext) => ext.id == provider) - .firstOrNull, - ); - }, - onReorderItem: (oldIndex, newIndex) { - setState(() { - final item = _providers.removeAt(oldIndex); - _providers.insert(newIndex, item); - _hasChanges = true; - }); - }, - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - size: 20, - color: colorScheme.tertiary, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.providerPriorityInfo, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], + return PrioritySettingsScaffold( + hasChanges: _hasChanges, + title: context.l10n.providerPriorityTitle, + description: context.l10n.providerPriorityDescription, + descriptionPadding: const EdgeInsets.all(16), + infoText: context.l10n.providerPriorityInfo, + onSave: _saveChanges, + onConfirmDiscard: showDiscardChangesDialog, + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _providers.length, + itemBuilder: (context, index) { + final provider = _providers[index]; + final extension = ref + .read(extensionProvider) + .extensions + .where((ext) => ext.id == provider) + .firstOrNull; + return ReorderablePriorityItem( + key: ValueKey(provider), + index: index, + isFirst: index == 0, + icon: Icons.extension, + iconColor: Theme.of(context).colorScheme.secondary, + name: extension?.displayName ?? provider, + subtitle: context.l10n.providerExtension, + ); + }, + onReorderItem: (oldIndex, newIndex) { + setState(() { + final item = _providers.removeAt(oldIndex); + _providers.insert(newIndex, item); + _hasChanges = true; + }); + }, + ), ), - ), + ], ); } - Future _confirmDiscard(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.dialogDiscardChanges), - content: Text(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; - } - Future _saveChanges() async { await ref.read(extensionProvider.notifier).setProviderPriority(_providers); if (!mounted) return; @@ -194,111 +99,3 @@ class _ProviderPriorityPageState extends ConsumerState { ); } } - -class _ProviderItem extends StatelessWidget { - final String provider; - final int index; - final bool isFirst; - final bool isLast; - final Extension? extension; - - const _ProviderItem({ - super.key, - required this.provider, - required this.index, - required this.isFirst, - required this.isLast, - this.extension, - }); - - @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; - - final info = _getProviderInfo(provider, extension); - - 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(info.icon, color: colorScheme.secondary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - info.name, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - Text( - context.l10n.providerExtension, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), - ], - ), - ), - ), - ), - ); - } - - _ProviderInfo _getProviderInfo(String provider, Extension? extension) { - return _ProviderInfo( - name: extension?.displayName ?? provider, - icon: Icons.extension, - ); - } -} - -class _ProviderInfo { - final String name; - final IconData icon; - - _ProviderInfo({required this.name, required this.icon}); -} diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index 505d7a29..2457b07b 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -241,21 +241,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { formats.addAll(['AAC', 'MP3', 'Opus']); } - String selectedFormat = formats.first; - String defaultBitrateForFormat(String format) { - if (format == 'Opus') return '128k'; - if (format == 'AAC') return '256k'; - return '320k'; - } - - String selectedBitrate = defaultBitrateForFormat(selectedFormat); - bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); - int? selectedMaxBitDepth; - int? selectedMaxSampleRate; - String selectedDither = 'none'; - String selectedResampler = 'swr'; - final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth); - final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate); + final labels = context.l10n.losslessConversionLabels; showModalBottomSheet( context: context, @@ -264,438 +250,43 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (sheetContext) { - return StatefulBuilder( - builder: (context, setSheetState) { - final colorScheme = Theme.of(context).colorScheme; - final labels = context.l10n.losslessConversionLabels; - final bitrates = ['128k', '192k', '256k', '320k']; - - Widget card({required Widget child}) { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: settingsGroupColor(context), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ), - child: child, - ); - } - - Widget sectionLabel(String text) { - return Padding( - padding: const EdgeInsets.only(left: 2, bottom: 12), - child: Text( - text, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.1, - ), - ), - ); - } - - Widget choice({ - required String label, - required bool selected, - required VoidCallback onTap, - }) { - return Material( - color: selected - ? colorScheme.primaryContainer - : colorScheme.surface, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 11, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: selected - ? Colors.transparent - : colorScheme.outlineVariant.withValues(alpha: 0.6), + builder: (sheetContext) => BatchConvertSheet( + formats: formats, + title: context.l10n.trackConvertTitle, + subtitle: currentFormat, + sourceIsLossless: isLosslessSource, + sourceBitDepth: bitDepth, + sourceSampleRate: sampleRate, + confirmLabelBuilder: + (format, bitrate, isLosslessTarget, losslessQuality) { + return isLosslessTarget + ? context.l10n.trackConvertActionLabelLossless( + currentFormat, + format, + losslessQualityLabel( + losslessQuality, + originalLabel: labels.original, + originalQualityLabel: labels.originalQuality, ), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurface, - fontWeight: selected - ? FontWeight.w600 - : FontWeight.w500, - ), - ), - ), - ), - ); - } - - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(context).bottom, - ), - child: DraggableScrollableSheet( - initialChildSize: 0.85, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => SafeArea( - child: SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 18), - Text( - context.l10n.trackConvertTitle, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - currentFormat, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 20), - - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.trackConvertTargetFormat, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: formats.map((format) { - return choice( - label: format, - selected: format == selectedFormat, - onTap: () { - setSheetState(() { - selectedFormat = format; - isLosslessTarget = - isLosslessConversionTarget(format); - if (!isLosslessTarget) { - selectedBitrate = - defaultBitrateForFormat(format); - } else { - selectedMaxBitDepth = null; - selectedMaxSampleRate = null; - selectedDither = 'none'; - selectedResampler = 'swr'; - } - }); - }, - ); - }).toList(), - ), - ], - ), - ), - - if (!isLosslessTarget) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel(context.l10n.trackConvertBitrate), - Wrap( - spacing: 8, - runSpacing: 8, - children: bitrates.map((br) { - return choice( - label: br, - selected: br == selectedBitrate, - onTap: () => setSheetState( - () => selectedBitrate = br, - ), - ); - }).toList(), - ), - ], - ), - ), - - if (isLosslessTarget && bitDepthOptions.isNotEmpty) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.audioAnalysisBitDepth, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - choice( - label: losslessBitDepthLabel( - null, - originalLabel: labels.original, - ), - selected: selectedMaxBitDepth == null, - onTap: () => setSheetState(() { - selectedMaxBitDepth = null; - selectedDither = 'none'; - }), - ), - ...bitDepthOptions.map((depth) { - return choice( - label: losslessBitDepthLabel( - depth, - originalLabel: labels.original, - ), - selected: depth == selectedMaxBitDepth, - onTap: () => setSheetState( - () => selectedMaxBitDepth = depth, - ), - ); - }), - ], - ), - ], - ), - ), - - if (isLosslessTarget && sampleRateOptions.isNotEmpty) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.audioAnalysisSampleRate, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - choice( - label: losslessSampleRateLabel( - null, - originalLabel: labels.original, - ), - selected: selectedMaxSampleRate == null, - onTap: () => setSheetState(() { - selectedMaxSampleRate = null; - selectedResampler = 'swr'; - }), - ), - ...sampleRateOptions.map((rate) { - return choice( - label: losslessSampleRateLabel( - rate, - originalLabel: labels.original, - ), - selected: rate == selectedMaxSampleRate, - onTap: () => setSheetState( - () => selectedMaxSampleRate = rate, - ), - ); - }), - ], - ), - ], - ), - ), - - if (isLosslessTarget && selectedMaxBitDepth != null) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.trackConvertDithering, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: losslessDitherOptions.map((mode) { - return choice( - label: context.l10n - .losslessDitherOptionLabel(mode), - selected: mode == selectedDither, - onTap: () => setSheetState( - () => selectedDither = mode, - ), - ); - }).toList(), - ), - ], - ), - ), - - if (isLosslessTarget && selectedMaxSampleRate != null) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.trackConvertResampler, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: losslessResamplerOptions.map(( - mode, - ) { - return choice( - label: context.l10n - .losslessResamplerOptionLabel(mode), - selected: mode == selectedResampler, - onTap: () => setSheetState( - () => selectedResampler = mode, - ), - ); - }).toList(), - ), - ], - ), - ), - - if (isLosslessTarget && isLosslessSource) - Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(14), - ), - child: Row( - children: [ - Icon( - Icons.verified, - size: 18, - color: colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - selectedMaxBitDepth == null && - selectedMaxSampleRate == null - ? context.l10n.trackConvertLosslessHint - : context.l10n - .trackConvertLosslessOutputWithCap( - losslessQualityLabel( - LosslessConversionQuality( - maxBitDepth: - selectedMaxBitDepth, - maxSampleRate: - selectedMaxSampleRate, - ), - originalLabel: - labels.original, - originalQualityLabel: - labels.originalQuality, - ), - ), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.primary), - ), - ), - ], - ), - ), - - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () { - Navigator.pop(context); - _confirmAndConvert( - context: this.context, - sourceFormat: currentFormat, - targetFormat: selectedFormat, - bitrate: selectedBitrate, - losslessQuality: LosslessConversionQuality( - maxBitDepth: selectedMaxBitDepth, - maxSampleRate: selectedMaxSampleRate, - ), - losslessProcessing: - LosslessConversionProcessing( - dither: selectedDither, - resampler: selectedResampler, - ), - ); - }, - icon: const Icon(Icons.swap_horiz), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - label: Text( - isLosslessTarget - ? context.l10n - .trackConvertActionLabelLossless( - currentFormat, - selectedFormat, - losslessQualityLabel( - LosslessConversionQuality( - maxBitDepth: selectedMaxBitDepth, - maxSampleRate: - selectedMaxSampleRate, - ), - originalLabel: labels.original, - originalQualityLabel: - labels.originalQuality, - ), - ) - : context.l10n.trackConvertActionLabelLossy( - currentFormat, - selectedFormat, - selectedBitrate, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - }, - ); - }, + ) + : context.l10n.trackConvertActionLabelLossy( + currentFormat, + format, + bitrate, + ); + }, + onConvert: (format, bitrate, losslessQuality, losslessProcessing) { + Navigator.pop(sheetContext); + _confirmAndConvert( + context: this.context, + sourceFormat: currentFormat, + targetFormat: format, + bitrate: bitrate, + losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, + ); + }, + ), ); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 83fb8c2b..28a2b0af 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -29,6 +29,7 @@ import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; +import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/constants/music_services.dart'; diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart index ee6d41d7..efe5b346 100644 --- a/lib/widgets/batch_convert_sheet.dart +++ b/lib/widgets/batch_convert_sheet.dart @@ -9,7 +9,15 @@ class BatchConvertSheet extends StatefulWidget { final List 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 { ), ), - if (_isLosslessTarget) + if (_isLosslessTarget && widget.sourceIsLossless) Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 12), @@ -383,7 +393,18 @@ class _BatchConvertSheetState extends State { borderRadius: BorderRadius.circular(14), ), ), - label: Text(widget.confirmLabel), + label: Text( + widget.confirmLabelBuilder?.call( + _selectedFormat, + _selectedBitrate, + _isLosslessTarget, + LosslessConversionQuality( + maxBitDepth: _selectedMaxBitDepth, + maxSampleRate: _selectedMaxSampleRate, + ), + ) ?? + widget.confirmLabel!, + ), ), ), ], diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index 33acb392..effb5222 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -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, diff --git a/lib/widgets/discard_changes_dialog.dart b/lib/widgets/discard_changes_dialog.dart new file mode 100644 index 00000000..bb92cefb --- /dev/null +++ b/lib/widgets/discard_changes_dialog.dart @@ -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 showDiscardChangesDialog( + BuildContext context, { + String? content, +}) async { + final result = await showDialog( + 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; +} diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index df656ac0..44c3888c 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -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; - } -} diff --git a/lib/widgets/player_artwork.dart b/lib/widgets/player_artwork.dart new file mode 100644 index 00000000..78c29ceb --- /dev/null +++ b/lib/widgets/player_artwork.dart @@ -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; + } +} diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index 30499b11..1e1cf82b 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -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 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), ); } diff --git a/lib/widgets/reorderable_priority_item.dart b/lib/widgets/reorderable_priority_item.dart new file mode 100644 index 00000000..a593bad8 --- /dev/null +++ b/lib/widgets/reorderable_priority_item.dart @@ -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), + ], + ), + ), + ), + ), + ); + } +}