chore: fix artist top tracks play button not showing loading indicator

This commit is contained in:
Kingkor Roy Tirtho 2025-09-05 17:53:06 +06:00
parent 2e48ac380b
commit 69d50eec35
4 changed files with 359 additions and 336 deletions

View File

@ -101,7 +101,9 @@ extension ToMetadataSpotubeFullTrackObject on SpotubeFullTrackObject {
albumArtist: artists.map((a) => a.name).join(", "), albumArtist: artists.map((a) => a.name).join(", "),
year: album.releaseDate == null year: album.releaseDate == null
? 1970 ? 1970
: DateTime.parse(album.releaseDate!).year, : DateTime.tryParse(album.releaseDate!)?.year ??
int.tryParse(album.releaseDate!) ??
1970,
durationMs: durationMs.toDouble(), durationMs: durationMs.toDouble(),
fileSize: BigInt.from(fileLength), fileSize: BigInt.from(fileLength),
picture: imageBytes != null picture: imageBytes != null

View File

@ -1,5 +1,7 @@
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:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
@ -19,6 +21,7 @@ class ArtistPageTopTracks extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isLoading = useState(false);
final playlist = ref.watch(audioPlayerProvider); final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
@ -40,46 +43,54 @@ class ArtistPageTopTracks extends HookConsumerWidget {
final topTracks = topTracksQuery.asData?.value.items ?? final topTracks = topTracksQuery.asData?.value.items ??
List.generate(10, (index) => FakeData.track); List.generate(10, (index) => FakeData.track);
void playPlaylist(List<SpotubeFullTrackObject> tracks, void playPlaylist(
{SpotubeTrackObject? currentTrack}) async { List<SpotubeFullTrackObject> tracks, {
SpotubeTrackObject? currentTrack,
}) async {
isLoading.value = true;
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
try {
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice == null) return;
if (isRemoteDevice == null) return; if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remotePlaylist = ref.read(queueProvider);
if (isRemoteDevice) { final isPlaylistPlaying = remotePlaylist.containsTracks(tracks);
final remotePlayback = ref.read(connectProvider.notifier);
final remotePlaylist = ref.read(queueProvider);
final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); if (!isPlaylistPlaying) {
await remotePlayback.load(
if (!isPlaylistPlaying) { WebSocketLoadEventData.playlist(
await remotePlayback.load( tracks: tracks,
WebSocketLoadEventData.playlist( collection: null,
tracks: tracks, initialIndex:
collection: null, tracks.indexWhere((s) => s.id == currentTrack?.id),
),
);
} else if (isPlaylistPlaying &&
currentTrack.id != remotePlaylist.activeTrack?.id) {
final index = playlist.tracks
.toList()
.indexWhere((s) => s.id == currentTrack!.id);
await remotePlayback.jumpTo(index);
}
} else {
if (!isPlaylistPlaying) {
playlistNotifier.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
), autoPlay: true,
); );
} else if (isPlaylistPlaying && } else if (isPlaylistPlaying &&
currentTrack.id != remotePlaylist.activeTrack?.id) { currentTrack.id != playlist.activeTrack?.id) {
final index = playlist.tracks await playlistNotifier.jumpToTrack(currentTrack);
.toList() }
.indexWhere((s) => s.id == currentTrack!.id);
await remotePlayback.jumpTo(index);
}
} else {
if (!isPlaylistPlaying) {
playlistNotifier.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != playlist.activeTrack?.id) {
await playlistNotifier.jumpToTrack(currentTrack);
} }
} finally {
isLoading.value = false;
} }
} }
@ -120,12 +131,19 @@ class ArtistPageTopTracks extends HookConsumerWidget {
const SizedBox(width: 5), const SizedBox(width: 5),
IconButton.primary( IconButton.primary(
shape: ButtonShape.circle, shape: ButtonShape.circle,
enabled: !isPlaylistPlaying, enabled: !isPlaylistPlaying && !isLoading.value,
icon: Skeleton.keep( icon: isLoading.value
child: Icon( ? CircularProgressIndicator(
isPlaylistPlaying ? SpotubeIcons.pause : SpotubeIcons.play, size: 20 * context.theme.scaling,
), color: theme.colorScheme.primaryForeground,
), )
: Skeleton.keep(
child: Icon(
isPlaylistPlaying
? SpotubeIcons.pause
: SpotubeIcons.play,
),
),
onPressed: () => playPlaylist(topTracks.toList()), onPressed: () => playPlaylist(topTracks.toList()),
) )
], ],

View File

@ -98,315 +98,311 @@ class LocalLibraryPage extends HookConsumerWidget {
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
headers: [ headers: [
TitleBar( TitleBar(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 0, vertical: 0,
), ),
surfaceBlur: 0, surfaceBlur: 0,
leading: const [BackButton()], leading: const [BackButton()],
title: Column( title: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
isDownloads isDownloads
? context.l10n.downloads ? context.l10n.downloads
: isCache : isCache
? context.l10n.cache_folder.capitalize() ? context.l10n.cache_folder.capitalize()
: location, : location,
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: directorySize, future: directorySize,
builder: (context, snapshot) { builder: (context, snapshot) {
return Text( return Text(
"${(snapshot.data ?? 0)} GB", "${(snapshot.data ?? 0)} GB",
).xSmall().muted(); ).xSmall().muted();
}, },
) )
],
),
backgroundColor: Colors.transparent,
trailingGap: 10,
trailing: [
if (isCache) ...[
IconButton.outline(
size: ButtonSize.small,
icon: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.delete),
Text(context.l10n.clear_cache)
],
).xSmall().iconSmall(),
onPressed: () async {
final accepted = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.clear_cache_confirmation),
actions: [
Button.outline(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(context.l10n.decline),
),
Button.destructive(
onPressed: () async {
Navigator.of(context).pop(true);
},
child: Text(context.l10n.accept),
),
],
),
);
if (accepted ?? false) return;
final cacheDir = Directory(
await UserPreferencesNotifier.getMusicCacheDir(),
);
if (cacheDir.existsSync()) {
await cacheDir.delete(recursive: true);
}
},
),
IconButton.outline(
size: ButtonSize.small,
icon: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.export),
Text(
context.l10n.export,
)
],
).xSmall().iconSmall(),
onPressed: () async {
final exportPath =
await FilePicker.platform.getDirectoryPath();
if (exportPath == null) return;
final exportDirectory = Directory(exportPath);
if (!exportDirectory.existsSync()) {
await exportDirectory.create(recursive: true);
}
final cacheDir = Directory(
await UserPreferencesNotifier.getMusicCacheDir());
if (!context.mounted) return;
await showDialog(
context: context,
builder: (context) {
return LocalFolderCacheExportDialog(
cacheDir: cacheDir,
exportDir: exportDirectory,
);
},
);
},
),
]
], ],
), ),
], backgroundColor: Colors.transparent,
child: LayoutBuilder( trailingGap: 10,
builder: (context, constraints) => Column( trailing: [
if (isCache) ...[
IconButton.outline(
size: ButtonSize.small,
icon: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Padding( const Icon(SpotubeIcons.delete),
padding: const EdgeInsets.all(8.0), Text(context.l10n.clear_cache)
child: Row( ],
children: [ ).xSmall().iconSmall(),
const Gap(5), onPressed: () async {
Button.primary( final accepted = await showDialog<bool>(
onPressed: trackSnapshot.asData?.value != null context: context,
? () async { builder: (context) => AlertDialog(
if (trackSnapshot title: Text(context.l10n.clear_cache_confirmation),
.asData?.value.isNotEmpty == actions: [
true) { Button.outline(
if (!isPlaylistPlaying) { onPressed: () {
await playLocalTracks( Navigator.of(context).pop(false);
ref, },
trackSnapshot child: Text(context.l10n.decline),
.asData!.value[location] ?? ),
[], Button.destructive(
); onPressed: () async {
} Navigator.of(context).pop(true);
} },
} child: Text(context.l10n.accept),
: null, ),
leading: Icon( ],
isPlaylistPlaying ),
? SpotubeIcons.stop );
: SpotubeIcons.play,
), if (accepted ?? false) return;
child: Text(context.l10n.play),
), final cacheDir = Directory(
const Spacer(), await UserPreferencesNotifier.getMusicCacheDir(),
if (constraints.smAndDown) );
ExpandableSearchButton(
isFiltering: isFiltering.value, if (cacheDir.existsSync()) {
onPressed: (value) => isFiltering.value = value, await cacheDir.delete(recursive: true);
searchFocus: searchFocus, }
) },
else ),
ConstrainedBox( IconButton.outline(
constraints: BoxConstraints( size: ButtonSize.small,
maxWidth: 300 * scale, icon: Column(
maxHeight: 38 * scale, mainAxisSize: MainAxisSize.min,
), children: [
child: ExpandableSearchField( const Icon(SpotubeIcons.export),
isFiltering: true, Text(
onChangeFiltering: (value) {}, context.l10n.export,
searchController: searchController, )
searchFocus: searchFocus, ],
), ).xSmall().iconSmall(),
), onPressed: () async {
const Gap(5), final exportPath =
SortTracksDropdown( await FilePicker.platform.getDirectoryPath();
value: sortBy.value,
onChanged: (value) { if (exportPath == null) return;
sortBy.value = value; final exportDirectory = Directory(exportPath);
},
), if (!exportDirectory.existsSync()) {
const Gap(5), await exportDirectory.create(recursive: true);
IconButton.outline( }
icon: const Icon(SpotubeIcons.refresh),
onPressed: () { final cacheDir = Directory(
ref.invalidate(localTracksProvider); await UserPreferencesNotifier.getMusicCacheDir());
},
) if (!context.mounted) return;
], await showDialog(
context: context,
builder: (context) {
return LocalFolderCacheExportDialog(
cacheDir: cacheDir,
exportDir: exportDirectory,
);
},
);
},
),
]
],
),
],
child: LayoutBuilder(
builder: (context, constraints) => Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const Gap(5),
Button.primary(
onPressed: trackSnapshot.asData?.value != null
? () async {
if (trackSnapshot.asData?.value.isNotEmpty ==
true) {
if (!isPlaylistPlaying) {
await playLocalTracks(
ref,
trackSnapshot.asData!.value[location] ?? [],
);
}
}
}
: null,
leading: Icon(
isPlaylistPlaying
? SpotubeIcons.stop
: SpotubeIcons.play,
),
child: Text(context.l10n.play),
),
const Spacer(),
if (constraints.smAndDown)
ExpandableSearchButton(
isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus,
)
else
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 300 * scale,
maxHeight: 38 * scale,
),
child: ExpandableSearchField(
isFiltering: true,
onChangeFiltering: (value) {},
searchController: searchController,
searchFocus: searchFocus,
), ),
), ),
ExpandableSearchField( const Gap(5),
searchController: searchController, SortTracksDropdown(
searchFocus: searchFocus, value: sortBy.value,
isFiltering: isFiltering.value, onChanged: (value) {
onChangeFiltering: (value) => isFiltering.value = value, sortBy.value = value;
), },
HookBuilder(builder: (context) { ),
return trackSnapshot.when( const Gap(5),
data: (tracks) { IconButton.outline(
final sortedTracks = useMemoized(() { icon: const Icon(SpotubeIcons.refresh),
return ServiceUtils.sortTracks( onPressed: () {
tracks[location] ?? ref.invalidate(localTracksProvider);
<SpotubeLocalTrackObject>[], },
sortBy.value); )
}, [sortBy.value, tracks]); ],
),
),
ExpandableSearchField(
searchController: searchController,
searchFocus: searchFocus,
isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
),
HookBuilder(builder: (context) {
return trackSnapshot.when(
data: (tracks) {
final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(
tracks[location] ?? <SpotubeLocalTrackObject>[],
sortBy.value);
}, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() { final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) { if (searchController.text.isEmpty) {
return sortedTracks; return sortedTracks;
} }
return sortedTracks return sortedTracks
.map((e) => ( .map((e) => (
weightedRatio( weightedRatio(
"${e.name} - ${e.artists.asString()}", "${e.name} - ${e.artists.asString()}",
searchController.text, searchController.text,
),
e,
))
.toList()
.sorted(
(a, b) => b.$1.compareTo(a.$1),
)
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList()
.toList();
}, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading &&
filteredTracks.isEmpty) {
return Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Undraw(
illustration: UndrawIllustration.empty,
height: 200 * scale,
color: context.theme.colorScheme.primary,
),
const Gap(10),
Text(
context.l10n.nothing_found,
textAlign: TextAlign.center,
).muted().small()
],
), ),
); e,
} ))
.toList()
.sorted(
(a, b) => b.$1.compareTo(a.$1),
)
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList()
.toList();
}, [searchController.text, sortedTracks]);
return Expanded( if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
child: material.RefreshIndicator.adaptive( return Expanded(
onRefresh: () async { child: Column(
ref.invalidate(localTracksProvider); mainAxisAlignment: MainAxisAlignment.center,
}, children: [
child: InterScrollbar( Undraw(
controller: controller, illustration: UndrawIllustration.empty,
child: Skeletonizer( height: 200 * scale,
enabled: trackSnapshot.isLoading, color: context.theme.colorScheme.primary,
child: ListView.builder( ),
controller: controller, const Gap(10),
physics: Text(
const AlwaysScrollableScrollPhysics(), context.l10n.nothing_found,
itemCount: trackSnapshot.isLoading textAlign: TextAlign.center,
? 5 ).muted().small()
: filteredTracks.length, ],
itemBuilder: (context, index) { ),
if (trackSnapshot.isLoading) { );
return TrackTile( }
playlist: playlist,
track: FakeData.track,
index: index,
);
}
final track = filteredTracks[index]; return Expanded(
return TrackTile( child: material.RefreshIndicator.adaptive(
index: index, onRefresh: () async {
playlist: playlist, ref.invalidate(localTracksProvider);
track: track, },
userPlaylist: false, child: InterScrollbar(
onTap: () async { controller: controller,
await playLocalTracks( child: Skeletonizer(
ref, enabled: trackSnapshot.isLoading,
sortedTracks, child: ListView.builder(
currentTrack: track, controller: controller,
); physics: const AlwaysScrollableScrollPhysics(),
}, itemCount: trackSnapshot.isLoading
); ? 5
}, : filteredTracks.length,
), itemBuilder: (context, index) {
), if (trackSnapshot.isLoading) {
), return TrackTile(
), playlist: playlist,
); track: FakeData.track,
}, index: index,
loading: () => Expanded( );
child: Skeletonizer( }
enabled: true,
child: ListView.builder( final track = filteredTracks[index];
itemCount: 5, return TrackTile(
itemBuilder: (context, index) => TrackTile(
track: FakeData.track,
index: index, index: index,
playlist: playlist, playlist: playlist,
), track: track,
), userPlaylist: false,
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,
);
},
);
},
), ),
), ),
error: (error, stackTrace) => ),
Text(error.toString() + stackTrace.toString()), ),
); );
}) },
], loading: () => Expanded(
))), child: Skeletonizer(
enabled: true,
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()),
);
})
],
),
),
),
); );
} }
} }

View File

@ -393,6 +393,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
Future<SourcedTrack> refreshStream() async { Future<SourcedTrack> refreshStream() async {
List<TrackSource> validStreams = []; List<TrackSource> validStreams = [];
final stringBuffer = StringBuffer();
for (final source in sources) { for (final source in sources) {
final res = await globalDio.head( final res = await globalDio.head(
source.url, source.url,
@ -400,11 +401,17 @@ class YoutubeSourcedTrack extends SourcedTrack {
Options(validateStatus: (status) => status != null && status < 500), Options(validateStatus: (status) => status != null && status < 500),
); );
stringBuffer.writeln(
"[${query.id}] ${res.statusCode} ${source.quality} ${source.codec} ${source.bitrate}",
);
if (res.statusCode! < 400) { if (res.statusCode! < 400) {
validStreams.add(source); validStreams.add(source);
} }
} }
AppLogger.log.d(stringBuffer.toString());
if (validStreams.isEmpty) { if (validStreams.isEmpty) {
final manifest = final manifest =
await ref.read(youtubeEngineProvider).getStreamManifest(info.id); await ref.read(youtubeEngineProvider).getStreamManifest(info.id);