feat: enhance image handling

This commit is contained in:
Kingkor Roy Tirtho 2025-07-16 22:34:59 +06:00
parent f23a078b64
commit 90f9cc28eb
7 changed files with 151 additions and 47 deletions

View File

@ -5,7 +5,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
@ -71,6 +71,13 @@ class TrackTile extends HookConsumerWidget {
final isSelected = isPlaying || isLoading.value; 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 LayoutBuilder(builder: (context, constrains) {
return Listener( return Listener(
onPointerDown: (event) { onPointerDown: (event) {
@ -147,11 +154,7 @@ class TrackTile extends HookConsumerWidget {
borderRadius: theme.borderRadiusMd, borderRadius: theme.borderRadiusMd,
image: DecorationImage( image: DecorationImage(
fit: BoxFit.cover, fit: BoxFit.cover,
image: UniversalImage.imageProvider( image: imageProvider,
(track.album.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
),
), ),
), ),
), ),

View File

@ -19,25 +19,65 @@ enum ImagePlaceholder {
online, online,
} }
extension SpotubeImageExtensions on List<SpotubeImageObject>? { final placeholderUrlMap = {
String asUrlString({
int index = 1,
required ImagePlaceholder placeholder,
}) {
final String placeholderUrl = {
ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, ImagePlaceholder.albumArt: Assets.albumPlaceholder.path,
ImagePlaceholder.artist: Assets.userPlaceholder.path, ImagePlaceholder.artist: Assets.userPlaceholder.path,
ImagePlaceholder.collection: Assets.placeholder.path, ImagePlaceholder.collection: Assets.placeholder.path,
ImagePlaceholder.online: ImagePlaceholder.online:
"https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png",
}[placeholder]!; };
extension SpotubeImageExtensions on List<SpotubeImageObject>? {
/// Returns the URL of the image at the specified index.
String asUrlString({
int index = 1,
required ImagePlaceholder placeholder,
}) {
final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!)); final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!));
return sortedImage != null && sortedImage.isNotEmpty return sortedImage != null && sortedImage.isNotEmpty
? sortedImage[ ? sortedImage[
index > sortedImage.length - 1 ? sortedImage.length - 1 : index] index > sortedImage.length - 1 ? sortedImage.length - 1 : index]
.url .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; : placeholderUrl;
} }
} }

View File

@ -42,32 +42,36 @@ class AlbumCard extends HookConsumerWidget {
final historyNotifier = ref.read(playbackHistoryActionsProvider); final historyNotifier = ref.read(playbackHistoryActionsProvider);
final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
bool isPlaylistPlaying = useMemoized( final isPlaylistPlaying = useMemoized<bool>(
() => playlist.containsCollection(album.id), () => playlist.containsCollection(album.id),
[playlist, album.id], [playlist, album.id],
); );
final updating = useState(false); final updating = useState(false);
Future<List<SpotubeFullTrackObject>> fetchAllTrack() async { final fetchAllTrack = useCallback(() async {
await ref.read(metadataPluginAlbumTracksProvider(album.id).future); await ref.read(metadataPluginAlbumTracksProvider(album.id).future);
return ref return ref
.read(metadataPluginAlbumTracksProvider(album.id).notifier) .read(metadataPluginAlbumTracksProvider(album.id).notifier)
.fetchAll(); .fetchAll();
} }, [album.id, ref]);
var imageUrl = album.images.asUrlString( final imageUrl = useMemoized(
placeholder: ImagePlaceholder.collection, () => album.images.from200PxTo300PxOrSmallestImage(
ImagePlaceholder.collection,
),
[album.images],
); );
var isLoading =
final isLoading =
(isPlaylistPlaying && isFetchingActiveTrack) || updating.value; (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.navigateTo(AlbumRoute(id: album.id, album: album));
} }, [context, album]);
void onPlaybuttonPressed() async { final onPlaybuttonPressed = useCallback(() async {
updating.value = true; updating.value = true;
try { try {
if (isPlaylistPlaying) { if (isPlaylistPlaying) {
@ -96,9 +100,20 @@ class AlbumCard extends HookConsumerWidget {
} finally { } finally {
updating.value = false; updating.value = false;
} }
} }, [
isPlaylistPlaying,
playing,
audioPlayer,
fetchAllTrack,
context,
ref,
playlistNotifier,
album,
historyNotifier,
updating
]);
void onAddToQueuePressed() async { final onAddToQueuePressed = useCallback(() async {
if (isPlaylistPlaying) { if (isPlaylistPlaying) {
return; return;
} }
@ -135,7 +150,16 @@ class AlbumCard extends HookConsumerWidget {
} finally { } finally {
updating.value = false; updating.value = false;
} }
} }, [
isPlaylistPlaying,
updating.value,
fetchAllTrack,
playlistNotifier,
album.id,
historyNotifier,
album,
context
]);
if (_isTile) { if (_isTile) {
return PlaybuttonTile( return PlaybuttonTile(

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/collections/routes.gr.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/playbutton_view/playbutton_card.dart'; import 'package:spotube/components/playbutton_view/playbutton_card.dart';
@ -41,7 +41,8 @@ class PlaylistCard extends HookConsumerWidget {
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
bool isPlaylistPlaying = useMemoized(
final isPlaylistPlaying = useMemoized<bool>(
() => playlistQueue.containsCollection(playlist.id), () => playlistQueue.containsCollection(playlist.id),
[playlistQueue, playlist.id], [playlistQueue, playlist.id],
); );
@ -49,7 +50,7 @@ class PlaylistCard extends HookConsumerWidget {
final updating = useState(false); final updating = useState(false);
final me = ref.watch(metadataPluginUserProvider); final me = ref.watch(metadataPluginUserProvider);
Future<List<SpotubeTrackObject>> fetchInitialTracks() async { final fetchInitialTracks = useCallback(() async {
if (playlist.id == 'user-liked-tracks') { if (playlist.id == 'user-liked-tracks') {
final tracks = await ref.read(metadataPluginSavedTracksProvider.future); final tracks = await ref.read(metadataPluginSavedTracksProvider.future);
return tracks.items; return tracks.items;
@ -59,9 +60,9 @@ class PlaylistCard extends HookConsumerWidget {
.read(metadataPluginPlaylistTracksProvider(playlist.id).future); .read(metadataPluginPlaylistTracksProvider(playlist.id).future);
return result.items; return result.items;
} }, [playlist.id, ref]);
Future<List<SpotubeTrackObject>> fetchAllTracks() async { final fetchAllTracks = useCallback(() async {
await fetchInitialTracks(); await fetchInitialTracks();
if (playlist.id == 'user-liked-tracks') { if (playlist.id == 'user-liked-tracks') {
@ -71,13 +72,13 @@ class PlaylistCard extends HookConsumerWidget {
return ref return ref
.read(metadataPluginPlaylistTracksProvider(playlist.id).notifier) .read(metadataPluginPlaylistTracksProvider(playlist.id).notifier)
.fetchAll(); .fetchAll();
} }, [playlist.id, ref, fetchInitialTracks]);
void onTap() { final onTap = useCallback(() {
context.navigateTo(PlaylistRoute(id: playlist.id, playlist: playlist)); context.navigateTo(PlaylistRoute(id: playlist.id, playlist: playlist));
} }, [context, playlist]);
void onPlaybuttonPressed() async { final onPlaybuttonPressed = useCallback(() async {
try { try {
updating.value = true; updating.value = true;
if (isPlaylistPlaying && playing) { if (isPlaylistPlaying && playing) {
@ -97,7 +98,7 @@ class PlaylistCard extends HookConsumerWidget {
final allTracks = await fetchAllTracks(); final allTracks = await fetchAllTracks();
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData.playlist( WebSocketLoadEventData.playlist(
tracks: allTracks as List<SpotubeFullTrackObject>, tracks: allTracks,
collection: playlist, collection: playlist,
), ),
); );
@ -116,9 +117,23 @@ class PlaylistCard extends HookConsumerWidget {
updating.value = false; 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; updating.value = true;
try { try {
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
@ -155,11 +170,24 @@ class PlaylistCard extends HookConsumerWidget {
} finally { } finally {
updating.value = false; updating.value = false;
} }
} }, [
isPlaylistPlaying,
fetchAllTracks,
playlistNotifier,
playlist.id,
historyNotifier,
playlist,
context,
updating
]);
final imageUrl = playlist.images.asUrlString( final imageUrl = useMemoized(
placeholder: ImagePlaceholder.collection, () => playlist.images.from200PxTo300PxOrSmallestImage(
ImagePlaceholder.collection,
),
[playlist.images],
); );
final isLoading = final isLoading =
(isPlaylistPlaying && isFetchingActiveTrack) || updating.value; (isPlaylistPlaying && isFetchingActiveTrack) || updating.value;
final isOwner = playlist.owner.id == me.asData?.value?.id && final isOwner = playlist.owner.id == me.asData?.value?.id &&

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';

View File

@ -85,7 +85,7 @@ class HomePage extends HookConsumerWidget {
}; };
}, },
), ),
const HomePageBrowseSection(), const SliverSafeArea(sliver: HomePageBrowseSection()),
], ],
), ),
)); ));

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.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/hooks/configurators/use_check_yt_dlp_installed.dart';
import 'package:spotube/modules/root/bottom_player.dart'; import 'package:spotube/modules/root/bottom_player.dart';
import 'package:spotube/modules/root/sidebar/sidebar.dart'; import 'package:spotube/modules/root/sidebar/sidebar.dart';
@ -43,15 +44,23 @@ class RootAppPage extends HookConsumerWidget {
final scaffold = MediaQuery.removeViewInsets( final scaffold = MediaQuery.removeViewInsets(
context: context, context: context,
removeBottom: true, removeBottom: true,
child: const SafeArea( child: SafeArea(
top: false, top: false,
child: Scaffold( child: Scaffold(
footers: [ footers: const [
BottomPlayer(), BottomPlayer(),
SpotubeNavigationBar(), SpotubeNavigationBar(),
], ],
floatingFooter: true, 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(),
),
),
), ),
), ),
); );