From 90f9cc28eba8dde97934ce987334f6533f75c10b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 16 Jul 2025 22:34:59 +0600 Subject: [PATCH] feat: enhance image handling --- lib/components/track_tile/track_tile.dart | 15 +++--- lib/models/metadata/image.dart | 56 ++++++++++++++++++---- lib/modules/album/album_card.dart | 50 ++++++++++++++----- lib/modules/playlist/playlist_card.dart | 58 +++++++++++++++++------ lib/modules/root/bottom_player.dart | 2 +- lib/pages/home/home.dart | 2 +- lib/pages/root/root_app.dart | 15 ++++-- 7 files changed, 151 insertions(+), 47 deletions(-) diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index a3207353..41def4b0 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -5,7 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -71,6 +71,13 @@ class TrackTile extends HookConsumerWidget { final isSelected = isPlaying || isLoading.value; + final imageProvider = useMemoized( + () => UniversalImage.imageProvider( + (track.album.images).smallest(ImagePlaceholder.albumArt), + ), + [track.album.images], + ); + return LayoutBuilder(builder: (context, constrains) { return Listener( onPointerDown: (event) { @@ -147,11 +154,7 @@ class TrackTile extends HookConsumerWidget { borderRadius: theme.borderRadiusMd, image: DecorationImage( fit: BoxFit.cover, - image: UniversalImage.imageProvider( - (track.album.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - ), + image: imageProvider, ), ), ), diff --git a/lib/models/metadata/image.dart b/lib/models/metadata/image.dart index 6027c3aa..0467dfd6 100644 --- a/lib/models/metadata/image.dart +++ b/lib/models/metadata/image.dart @@ -19,25 +19,65 @@ enum ImagePlaceholder { online, } +final placeholderUrlMap = { + ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, + ImagePlaceholder.artist: Assets.userPlaceholder.path, + ImagePlaceholder.collection: Assets.placeholder.path, + ImagePlaceholder.online: + "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", +}; + extension SpotubeImageExtensions on List? { + /// Returns the URL of the image at the specified index. String asUrlString({ int index = 1, required ImagePlaceholder placeholder, }) { - final String placeholderUrl = { - ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, - ImagePlaceholder.artist: Assets.userPlaceholder.path, - ImagePlaceholder.collection: Assets.placeholder.path, - ImagePlaceholder.online: - "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", - }[placeholder]!; - final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!)); return sortedImage != null && sortedImage.isNotEmpty ? sortedImage[ index > sortedImage.length - 1 ? sortedImage.length - 1 : index] .url + : placeholderUrlMap[placeholder]!; + } + + String smallest(ImagePlaceholder placeholder) { + final sortedImage = this?.sorted((a, b) { + final widthComparison = (a.width ?? 0).compareTo(b.width ?? 0); + if (widthComparison != 0) return widthComparison; + return (a.height ?? 0).compareTo(b.height ?? 0); + }); + + return sortedImage != null && sortedImage.isNotEmpty + ? sortedImage.first.url + : placeholderUrlMap[placeholder]!; + } + + String from200PxTo300PxOrSmallestImage([ + ImagePlaceholder placeholder = ImagePlaceholder.albumArt, + ]) { + final placeholderUrl = placeholderUrlMap[placeholder]!; + + // Sort images by width and height to find the smallest one + final sortedImage = this?.sorted((a, b) { + final widthComparison = (a.width ?? 0).compareTo(b.width ?? 0); + if (widthComparison != 0) return widthComparison; + return (a.height ?? 0).compareTo(b.height ?? 0); + }); + + return sortedImage != null && sortedImage.isNotEmpty + ? sortedImage.firstWhere( + (image) { + final width = image.width ?? 0; + final height = image.height ?? 0; + return width >= 200 && + height >= 200 && + width <= 300 && + height <= 300; + }, + orElse: () => sortedImage.first, + ).url : placeholderUrl; } } diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index b4809aed..80dfd55b 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -42,32 +42,36 @@ class AlbumCard extends HookConsumerWidget { final historyNotifier = ref.read(playbackHistoryActionsProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); - bool isPlaylistPlaying = useMemoized( + final isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id), [playlist, album.id], ); final updating = useState(false); - Future> fetchAllTrack() async { + final fetchAllTrack = useCallback(() async { await ref.read(metadataPluginAlbumTracksProvider(album.id).future); return ref .read(metadataPluginAlbumTracksProvider(album.id).notifier) .fetchAll(); - } + }, [album.id, ref]); - var imageUrl = album.images.asUrlString( - placeholder: ImagePlaceholder.collection, + final imageUrl = useMemoized( + () => album.images.from200PxTo300PxOrSmallestImage( + ImagePlaceholder.collection, + ), + [album.images], ); - var isLoading = + + final isLoading = (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; - var description = "${album.albumType.name} • ${album.artists.asString()}"; + final description = "${album.albumType.name} • ${album.artists.asString()}"; - void onTap() { + final onTap = useCallback(() { context.navigateTo(AlbumRoute(id: album.id, album: album)); - } + }, [context, album]); - void onPlaybuttonPressed() async { + final onPlaybuttonPressed = useCallback(() async { updating.value = true; try { if (isPlaylistPlaying) { @@ -96,9 +100,20 @@ class AlbumCard extends HookConsumerWidget { } finally { updating.value = false; } - } + }, [ + isPlaylistPlaying, + playing, + audioPlayer, + fetchAllTrack, + context, + ref, + playlistNotifier, + album, + historyNotifier, + updating + ]); - void onAddToQueuePressed() async { + final onAddToQueuePressed = useCallback(() async { if (isPlaylistPlaying) { return; } @@ -135,7 +150,16 @@ class AlbumCard extends HookConsumerWidget { } finally { updating.value = false; } - } + }, [ + isPlaylistPlaying, + updating.value, + fetchAllTrack, + playlistNotifier, + album.id, + historyNotifier, + album, + context + ]); if (_isTile) { return PlaybuttonTile( diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index 811b9332..71015bac 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; import 'package:spotube/components/playbutton_view/playbutton_card.dart'; @@ -41,7 +41,8 @@ class PlaylistCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - bool isPlaylistPlaying = useMemoized( + + final isPlaylistPlaying = useMemoized( () => playlistQueue.containsCollection(playlist.id), [playlistQueue, playlist.id], ); @@ -49,7 +50,7 @@ class PlaylistCard extends HookConsumerWidget { final updating = useState(false); final me = ref.watch(metadataPluginUserProvider); - Future> fetchInitialTracks() async { + final fetchInitialTracks = useCallback(() async { if (playlist.id == 'user-liked-tracks') { final tracks = await ref.read(metadataPluginSavedTracksProvider.future); return tracks.items; @@ -59,9 +60,9 @@ class PlaylistCard extends HookConsumerWidget { .read(metadataPluginPlaylistTracksProvider(playlist.id).future); return result.items; - } + }, [playlist.id, ref]); - Future> fetchAllTracks() async { + final fetchAllTracks = useCallback(() async { await fetchInitialTracks(); if (playlist.id == 'user-liked-tracks') { @@ -71,13 +72,13 @@ class PlaylistCard extends HookConsumerWidget { return ref .read(metadataPluginPlaylistTracksProvider(playlist.id).notifier) .fetchAll(); - } + }, [playlist.id, ref, fetchInitialTracks]); - void onTap() { + final onTap = useCallback(() { context.navigateTo(PlaylistRoute(id: playlist.id, playlist: playlist)); - } + }, [context, playlist]); - void onPlaybuttonPressed() async { + final onPlaybuttonPressed = useCallback(() async { try { updating.value = true; if (isPlaylistPlaying && playing) { @@ -97,7 +98,7 @@ class PlaylistCard extends HookConsumerWidget { final allTracks = await fetchAllTracks(); await remotePlayback.load( WebSocketLoadEventData.playlist( - tracks: allTracks as List, + tracks: allTracks, collection: playlist, ), ); @@ -116,9 +117,23 @@ class PlaylistCard extends HookConsumerWidget { updating.value = false; } } - } + }, [ + isPlaylistPlaying, + playing, + fetchInitialTracks, + context, + showSelectDeviceDialog, + ref, + connectProvider, + fetchAllTracks, + playlistNotifier, + playlist.id, + historyNotifier, + playlist, + updating + ]); - void onAddToQueuePressed() async { + final onAddToQueuePressed = useCallback(() async { updating.value = true; try { if (isPlaylistPlaying) return; @@ -155,11 +170,24 @@ class PlaylistCard extends HookConsumerWidget { } finally { updating.value = false; } - } + }, [ + isPlaylistPlaying, + fetchAllTracks, + playlistNotifier, + playlist.id, + historyNotifier, + playlist, + context, + updating + ]); - final imageUrl = playlist.images.asUrlString( - placeholder: ImagePlaceholder.collection, + final imageUrl = useMemoized( + () => playlist.images.from200PxTo300PxOrSmallestImage( + ImagePlaceholder.collection, + ), + [playlist.images], ); + final isLoading = (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; final isOwner = playlist.owner.id == me.asData?.value?.id && diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index 8af5d433..5d9fd35b 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/assets.gen.dart'; diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index d766fd2a..89f12f45 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -85,7 +85,7 @@ class HomePage extends HookConsumerWidget { }; }, ), - const HomePageBrowseSection(), + const SliverSafeArea(sliver: HomePageBrowseSection()), ], ), )); diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 65b97d4f..4cd02881 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/hooks/configurators/use_check_yt_dlp_installed.dart'; import 'package:spotube/modules/root/bottom_player.dart'; import 'package:spotube/modules/root/sidebar/sidebar.dart'; @@ -43,15 +44,23 @@ class RootAppPage extends HookConsumerWidget { final scaffold = MediaQuery.removeViewInsets( context: context, removeBottom: true, - child: const SafeArea( + child: SafeArea( top: false, child: Scaffold( - footers: [ + footers: const [ BottomPlayer(), SpotubeNavigationBar(), ], floatingFooter: true, - child: Sidebar(child: AutoRouter()), + child: Sidebar( + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: MediaQuery.paddingOf(context) + .copyWith(bottom: 100 * context.theme.scaling), + ), + child: const AutoRouter(), + ), + ), ), ), );