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: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,
),
),
),

View File

@ -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<SpotubeImageObject>? {
/// 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;
}
}

View File

@ -42,32 +42,36 @@ class AlbumCard extends HookConsumerWidget {
final historyNotifier = ref.read(playbackHistoryActionsProvider);
final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
bool isPlaylistPlaying = useMemoized(
final isPlaylistPlaying = useMemoized<bool>(
() => playlist.containsCollection(album.id),
[playlist, album.id],
);
final updating = useState(false);
Future<List<SpotubeFullTrackObject>> 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(

View File

@ -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<bool>(
() => 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<List<SpotubeTrackObject>> 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<List<SpotubeTrackObject>> 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<SpotubeFullTrackObject>,
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 &&

View File

@ -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';

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: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(),
),
),
),
),
);