From 69d50eec35e3238201986ac8befda341b433b217 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Sep 2025 17:53:06 +0600 Subject: [PATCH] chore: fix artist top tracks play button not showing loading indicator --- lib/models/metadata/track.dart | 4 +- lib/pages/artist/section/top_tracks.dart | 96 +-- .../user_local_tracks/local_folder.dart | 588 +++++++++--------- .../sourced_track/sources/youtube.dart | 7 + 4 files changed, 359 insertions(+), 336 deletions(-) diff --git a/lib/models/metadata/track.dart b/lib/models/metadata/track.dart index e62a54c2..ecf7f0a2 100644 --- a/lib/models/metadata/track.dart +++ b/lib/models/metadata/track.dart @@ -101,7 +101,9 @@ extension ToMetadataSpotubeFullTrackObject on SpotubeFullTrackObject { albumArtist: artists.map((a) => a.name).join(", "), year: album.releaseDate == null ? 1970 - : DateTime.parse(album.releaseDate!).year, + : DateTime.tryParse(album.releaseDate!)?.year ?? + int.tryParse(album.releaseDate!) ?? + 1970, durationMs: durationMs.toDouble(), fileSize: BigInt.from(fileLength), picture: imageBytes != null diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 9ec7314b..30745a01 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -1,5 +1,7 @@ +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:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -19,6 +21,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final isLoading = useState(false); final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); @@ -40,46 +43,54 @@ class ArtistPageTopTracks extends HookConsumerWidget { final topTracks = topTracksQuery.asData?.value.items ?? List.generate(10, (index) => FakeData.track); - void playPlaylist(List tracks, - {SpotubeTrackObject? currentTrack}) async { + void playPlaylist( + List tracks, { + SpotubeTrackObject? currentTrack, + }) async { + isLoading.value = true; + 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 remotePlayback = ref.read(connectProvider.notifier); - final remotePlaylist = ref.read(queueProvider); + final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); - final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); - - if (!isPlaylistPlaying) { - await remotePlayback.load( - WebSocketLoadEventData.playlist( - tracks: tracks, - collection: null, + if (!isPlaylistPlaying) { + await remotePlayback.load( + WebSocketLoadEventData.playlist( + tracks: tracks, + collection: null, + initialIndex: + 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), - ), - ); - } 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), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); + 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), IconButton.primary( shape: ButtonShape.circle, - enabled: !isPlaylistPlaying, - icon: Skeleton.keep( - child: Icon( - isPlaylistPlaying ? SpotubeIcons.pause : SpotubeIcons.play, - ), - ), + enabled: !isPlaylistPlaying && !isLoading.value, + icon: isLoading.value + ? CircularProgressIndicator( + size: 20 * context.theme.scaling, + color: theme.colorScheme.primaryForeground, + ) + : Skeleton.keep( + child: Icon( + isPlaylistPlaying + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + ), onPressed: () => playPlaylist(topTracks.toList()), ) ], diff --git a/lib/pages/library/user_local_tracks/local_folder.dart b/lib/pages/library/user_local_tracks/local_folder.dart index c256af7f..27af0f57 100644 --- a/lib/pages/library/user_local_tracks/local_folder.dart +++ b/lib/pages/library/user_local_tracks/local_folder.dart @@ -98,315 +98,311 @@ class LocalLibraryPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - headers: [ - TitleBar( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 0, - ), - surfaceBlur: 0, - leading: const [BackButton()], - title: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isDownloads - ? context.l10n.downloads - : isCache - ? context.l10n.cache_folder.capitalize() - : location, - ), - FutureBuilder( - future: directorySize, - builder: (context, snapshot) { - return Text( - "${(snapshot.data ?? 0)} GB", - ).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( - 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, - ); - }, - ); - }, - ), - ] + headers: [ + TitleBar( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 0, + ), + surfaceBlur: 0, + leading: const [BackButton()], + title: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isDownloads + ? context.l10n.downloads + : isCache + ? context.l10n.cache_folder.capitalize() + : location, + ), + FutureBuilder( + future: directorySize, + builder: (context, snapshot) { + return Text( + "${(snapshot.data ?? 0)} GB", + ).xSmall().muted(); + }, + ) ], ), - ], - child: LayoutBuilder( - builder: (context, constraints) => Column( + backgroundColor: Colors.transparent, + trailingGap: 10, + trailing: [ + if (isCache) ...[ + IconButton.outline( + size: ButtonSize.small, + icon: Column( + mainAxisSize: MainAxisSize.min, 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, - ), - ), - const Gap(5), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - const Gap(5), - IconButton.outline( - icon: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.invalidate(localTracksProvider); - }, - ) - ], + const Icon(SpotubeIcons.delete), + Text(context.l10n.clear_cache) + ], + ).xSmall().iconSmall(), + onPressed: () async { + final accepted = await showDialog( + 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, + ); + }, + ); + }, + ), + ] + ], + ), + ], + 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( - 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] ?? - [], - sortBy.value); - }, [sortBy.value, tracks]); + const Gap(5), + SortTracksDropdown( + value: sortBy.value, + onChanged: (value) { + sortBy.value = value; + }, + ), + const Gap(5), + IconButton.outline( + icon: const Icon(SpotubeIcons.refresh), + onPressed: () { + ref.invalidate(localTracksProvider); + }, + ) + ], + ), + ), + 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] ?? [], + sortBy.value); + }, [sortBy.value, tracks]); - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; - } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${e.artists.asString()}", - 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() - ], + final filteredTracks = useMemoized(() { + if (searchController.text.isEmpty) { + return sortedTracks; + } + return sortedTracks + .map((e) => ( + weightedRatio( + "${e.name} - ${e.artists.asString()}", + 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]); - return Expanded( - child: material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - child: Skeletonizer( - enabled: trackSnapshot.isLoading, - child: ListView.builder( - 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, - ); - } + 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() + ], + ), + ); + } - final track = filteredTracks[index]; - return TrackTile( - index: index, - playlist: playlist, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, - ), - ), - ), - ), - ); - }, - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TrackTile( - track: FakeData.track, + return Expanded( + child: material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(localTracksProvider); + }, + child: InterScrollbar( + controller: controller, + child: Skeletonizer( + enabled: trackSnapshot.isLoading, + child: ListView.builder( + 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, + ); + } + + final track = filteredTracks[index]; + return TrackTile( index: index, 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()), + ); + }) + ], + ), + ), + ), ); } } diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 83eba6af..c090c916 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -393,6 +393,7 @@ class YoutubeSourcedTrack extends SourcedTrack { Future refreshStream() async { List validStreams = []; + final stringBuffer = StringBuffer(); for (final source in sources) { final res = await globalDio.head( source.url, @@ -400,11 +401,17 @@ class YoutubeSourcedTrack extends SourcedTrack { Options(validateStatus: (status) => status != null && status < 500), ); + stringBuffer.writeln( + "[${query.id}] ${res.statusCode} ${source.quality} ${source.codec} ${source.bitrate}", + ); + if (res.statusCode! < 400) { validStreams.add(source); } } + AppLogger.log.d(stringBuffer.toString()); + if (validStreams.isEmpty) { final manifest = await ref.read(youtubeEngineProvider).getStreamManifest(info.id);