mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
refactor(home): move extension collection screens into their own library
ExtensionAlbumScreen, ExtensionPlaylistScreen and ExtensionArtistScreen and their exclusive helpers lived in home_tab_widgets.dart, a part of the home tab library. They now live in extension_collection_screens.dart, re-exported from home_tab.dart so existing importers keep working. artist_screen.dart and clickable_metadata.dart import the new library directly instead of home_tab.
This commit is contained in:
1 parent
4009855ba5
commit
bdb2e636e9
5 files changed
+883
-865
No files matched your search
@@ -36,7 +36,7 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
||||
import 'package:spotiflac_android/utils/ttl_cache.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
import 'package:spotiflac_android/screens/extension_collection_screens.dart'
|
||||
show ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/utils/local_playback.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/expressive_button.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/artist_concert.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/editorial_notes.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
/// Legacy `duration_ms` parsing used by extension album/playlist/artist track
|
||||
/// parsing, kept separate from [extractDurationMs] which also falls back to
|
||||
/// a `duration` (seconds) field these call sites intentionally ignore.
|
||||
int _legacyTrackDurationMs(Map<String, dynamic> data) {
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) return durationValue;
|
||||
if (durationValue is double) return durationValue.toInt();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Prefers the track's own cover, falling back to the parent
|
||||
/// album/playlist cover, without URL validation.
|
||||
String? _resolveTrackCoverUrl(String? trackCover, String? fallbackCover) {
|
||||
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
|
||||
return fallbackCover;
|
||||
}
|
||||
|
||||
/// Shared loading/error+retry scaffold for extension album/playlist/artist
|
||||
/// detail screens.
|
||||
class _LoadingOrErrorScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final Widget loadingBody;
|
||||
final VoidCallback onRetry;
|
||||
final bool collectionHeader;
|
||||
final String? coverUrl;
|
||||
|
||||
const _LoadingOrErrorScaffold({
|
||||
required this.title,
|
||||
required this.isLoading,
|
||||
required this.error,
|
||||
required this.loadingBody,
|
||||
required this.onRetry,
|
||||
this.collectionHeader = false,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading) {
|
||||
if (context.isMornye && collectionHeader) {
|
||||
return MornyeArtistSurface(
|
||||
imageSource: coverUrl,
|
||||
neutralActions: true,
|
||||
child: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leadingWidth: 68,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: loadingBody,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!context.isMornye && collectionHeader) {
|
||||
// The real header with the title and cover already known; only the
|
||||
// rows that wait for the tracks are skeletons.
|
||||
final cover = coverUrl;
|
||||
final hasCover = cover != null && cover.isNotEmpty;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
AlbumDetailHeader(
|
||||
title: title,
|
||||
expandedHeight: collectionHeaderExpandedHeight(context),
|
||||
showTitleInAppBar: false,
|
||||
paletteSource: hasCover ? cover : null,
|
||||
blurAndScrimBackground: hasCover,
|
||||
background: hasCover
|
||||
? CachedCoverImage(imageUrl: cover, fit: BoxFit.cover)
|
||||
: ColoredBox(color: colors.surfaceContainerHighest),
|
||||
coverBuilder: (context, size) => hasCover
|
||||
? CachedCoverImage(
|
||||
imageUrl: cover,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: ShimmerLoading(
|
||||
child: SkeletonBox(
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
subtitle: CollectionHeaderSkeletonRows.subtitle(),
|
||||
meta: CollectionHeaderSkeletonRows.meta(),
|
||||
actions: CollectionHeaderSkeletonRows.actions(context),
|
||||
),
|
||||
SliverToBoxAdapter(child: loadingBody),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
leadingWidth: context.isMornye ? 68 : null,
|
||||
leading: context.isMornye
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: loadingBody,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
context.friendlyError(error),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: onRetry,
|
||||
child: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Defers swapping the loading scaffold for the loaded screen until the push
|
||||
/// transition (and its Hero flight) has finished. Swapping mid-flight puts a
|
||||
/// second hero with the same tag on screen; the flight doesn't adopt it, so
|
||||
/// the user sees a static cover next to the flying one.
|
||||
mixin _RouteSettled<T extends StatefulWidget> on State<T> {
|
||||
bool _routeSettled = false;
|
||||
Animation<double>? _routeAnimation;
|
||||
|
||||
bool get routeSettled => _routeSettled;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_routeSettled) return;
|
||||
final animation = ModalRoute.of(context)?.animation;
|
||||
if (animation == null || animation.isCompleted) {
|
||||
_routeSettled = true;
|
||||
return;
|
||||
}
|
||||
if (!identical(animation, _routeAnimation)) {
|
||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||
_routeAnimation = animation;
|
||||
animation.addStatusListener(_onRouteStatus);
|
||||
}
|
||||
}
|
||||
|
||||
void _onRouteStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||
_routeAnimation = null;
|
||||
setState(() => _routeSettled = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading state for [ExtensionArtistScreen]: the real full-bleed header with
|
||||
/// the cover and artist name above the discography skeleton.
|
||||
class _ArtistLoadingScaffold extends StatelessWidget {
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const _ArtistLoadingScaffold({
|
||||
required this.artistName,
|
||||
required this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye) {
|
||||
return MornyeArtistSurface(
|
||||
imageSource: coverUrl,
|
||||
child: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
...MornyeArtistHeader(
|
||||
name: artistName,
|
||||
showTitle: false,
|
||||
artwork: coverUrl != null && coverUrl!.isNotEmpty
|
||||
? CachedCoverImage(
|
||||
imageUrl: coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
memCacheWidth: 800,
|
||||
)
|
||||
: const ShimmerLoading(
|
||||
child: SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
actions: const [
|
||||
ShimmerLoading(child: ArtistHeaderActionsSkeleton()),
|
||||
],
|
||||
).buildSlivers(context),
|
||||
const SliverToBoxAdapter(
|
||||
child: ArtistScreenSkeleton(showCoverHeader: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final edgeInset = detailHeaderEdgeInset(context);
|
||||
final url = coverUrl;
|
||||
final hasImage =
|
||||
url != null &&
|
||||
url.isNotEmpty &&
|
||||
Uri.tryParse(url)?.hasAuthority == true;
|
||||
|
||||
final Widget image = hasImage
|
||||
? CachedCoverImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
memCacheWidth: 800,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 420,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
image,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.3),
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
isDark
|
||||
? colorScheme.surface
|
||||
: Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
stops: const [0.0, 0.5, 0.75, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16 + edgeInset,
|
||||
right: 16 + edgeInset,
|
||||
bottom: 16,
|
||||
child: Text(
|
||||
artistName,
|
||||
style: Theme.of(context).textTheme.headlineLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
leadingWidth: kToolbarHeight + edgeInset,
|
||||
leading: Padding(
|
||||
padding: EdgeInsets.only(left: edgeInset),
|
||||
// Centered so the tight leading slot cannot stretch it.
|
||||
child: Center(
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: ArtistScreenSkeleton(showCoverHeader: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final String? initialAlbumType;
|
||||
final int? initialTotalTracks;
|
||||
|
||||
const ExtensionAlbumScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.albumId,
|
||||
required this.albumName,
|
||||
this.coverUrl,
|
||||
this.initialAlbumType,
|
||||
this.initialTotalTracks,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionAlbumScreen> createState() =>
|
||||
_ExtensionAlbumScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
String? _albumName;
|
||||
String? _description;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String? _artistId;
|
||||
String? _artistName;
|
||||
String? _albumType;
|
||||
int? _albumTotalTracks;
|
||||
String? _headerVideoUrl;
|
||||
String? _headerImageUrl;
|
||||
List<String> _audioTraits = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_albumType = normalizeOptionalString(widget.initialAlbumType);
|
||||
_albumTotalTracks = widget.initialTotalTracks;
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await runExtensionOperationWithVerificationRetry(
|
||||
extensionId: widget.extensionId,
|
||||
browserMode: ref
|
||||
.read(settingsProvider)
|
||||
.extensionVerificationBrowserMode,
|
||||
operation: () => PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'album',
|
||||
widget.albumId,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final albumInfo = result['album_info'] as Map<String, dynamic>? ?? result;
|
||||
final trackList =
|
||||
result['track_list'] as List<dynamic>? ??
|
||||
result['tracks'] as List<dynamic>?;
|
||||
if (trackList == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorNoTracksFound;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final artistId = (albumInfo['artist_id'] ?? albumInfo['artistId'])
|
||||
?.toString();
|
||||
final albumName =
|
||||
normalizeOptionalString(albumInfo['name']?.toString()) ??
|
||||
widget.albumName;
|
||||
final artistName = (albumInfo['artists'] ?? albumInfo['artist'])
|
||||
?.toString();
|
||||
final albumType =
|
||||
normalizeOptionalString(albumInfo['album_type']?.toString()) ??
|
||||
_albumType;
|
||||
final totalTracks =
|
||||
albumInfo['total_tracks'] as int? ?? _albumTotalTracks;
|
||||
final headerVideo = albumInfo['header_video']?.toString();
|
||||
final headerImage = albumInfo['header_image']?.toString();
|
||||
final audioTraits = (albumInfo['audio_traits'] as List?)
|
||||
?.map((e) => e.toString())
|
||||
.toList();
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumNameFallback: albumName,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_albumName = albumName;
|
||||
_description = albumDescriptionFromMetadata(albumInfo);
|
||||
_artistId = artistId;
|
||||
_artistName = artistName;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
|
||||
? headerVideo
|
||||
: _headerVideoUrl;
|
||||
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
|
||||
? headerImage
|
||||
: _headerImageUrl;
|
||||
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
|
||||
? audioTraits
|
||||
: _audioTraits;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = context.l10n.snackbarError(context.friendlyError(e));
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(
|
||||
Map<String, dynamic> data, {
|
||||
String? albumNameFallback,
|
||||
String? albumTypeFallback,
|
||||
int? totalTracksFallback,
|
||||
}) {
|
||||
final base = Track.fromBackendMap(data, source: widget.extensionId);
|
||||
return base.copyWith(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
albumName:
|
||||
normalizeOptionalString(data['album_name']?.toString()) ??
|
||||
albumNameFallback ??
|
||||
widget.albumName,
|
||||
albumArtist: normalizeOptionalString(data['album_artist']?.toString()),
|
||||
artistId: base.artistId ?? _artistId,
|
||||
albumId: base.albumId ?? widget.albumId,
|
||||
coverUrl: _resolveTrackCoverUrl(
|
||||
data['cover_url']?.toString(),
|
||||
widget.coverUrl,
|
||||
),
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
albumType: base.albumType ?? albumTypeFallback ?? _albumType,
|
||||
totalTracks: base.totalTracks ?? totalTracksFallback ?? _albumTotalTracks,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading || _error != null) {
|
||||
return _LoadingOrErrorScaffold(
|
||||
title: widget.albumName,
|
||||
collectionHeader: true,
|
||||
coverUrl: widget.coverUrl,
|
||||
isLoading: _isLoading,
|
||||
error: _error,
|
||||
loadingBody: const AlbumTrackListSkeleton(
|
||||
itemCount: 10,
|
||||
showCoverHeader: true,
|
||||
),
|
||||
onRetry: _fetchTracks,
|
||||
);
|
||||
}
|
||||
|
||||
return AlbumScreen(
|
||||
albumId: widget.albumId,
|
||||
albumName: _albumName ?? widget.albumName,
|
||||
description: _description,
|
||||
coverUrl: widget.coverUrl,
|
||||
headerVideoUrl: _headerVideoUrl,
|
||||
headerImageUrl: _headerImageUrl,
|
||||
audioTraits: _audioTraits,
|
||||
tracks: _tracks,
|
||||
extensionId: widget.extensionId,
|
||||
artistId: _artistId,
|
||||
artistName: _artistName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen for viewing extension playlist with track fetching
|
||||
class ExtensionPlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String playlistId;
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const ExtensionPlaylistScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.playlistId,
|
||||
required this.playlistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionPlaylistScreen> createState() =>
|
||||
_ExtensionPlaylistScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionPlaylistScreenState
|
||||
extends ConsumerState<ExtensionPlaylistScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String? _headerVideoUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await runExtensionOperationWithVerificationRetry(
|
||||
extensionId: widget.extensionId,
|
||||
browserMode: ref
|
||||
.read(settingsProvider)
|
||||
.extensionVerificationBrowserMode,
|
||||
operation: () => PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'playlist',
|
||||
widget.playlistId,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final trackList =
|
||||
result['track_list'] as List<dynamic>? ??
|
||||
result['tracks'] as List<dynamic>?;
|
||||
if (trackList == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorNoTracksFound;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
|
||||
final headerVideo = playlistInfo?['header_video']?.toString();
|
||||
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
|
||||
? headerVideo
|
||||
: _headerVideoUrl;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = context.l10n.snackbarError(context.friendlyError(e));
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final base = Track.fromBackendMap(
|
||||
data,
|
||||
source: widget.extensionId,
|
||||
playlistName: widget.playlistName,
|
||||
);
|
||||
return base.copyWith(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
coverUrl: _resolveTrackCoverUrl(
|
||||
data['cover_url']?.toString(),
|
||||
widget.coverUrl,
|
||||
),
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading || _error != null) {
|
||||
return SkeletonCrossfade(
|
||||
child: _LoadingOrErrorScaffold(
|
||||
title: widget.playlistName,
|
||||
collectionHeader: true,
|
||||
coverUrl: widget.coverUrl,
|
||||
isLoading: _isLoading,
|
||||
error: _error,
|
||||
loadingBody: const TrackListSkeleton(
|
||||
itemCount: 8,
|
||||
showCoverHeader: true,
|
||||
),
|
||||
onRetry: _fetchTracks,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SkeletonCrossfade(
|
||||
child: PlaylistScreen(
|
||||
playlistName: widget.playlistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
headerVideoUrl: _headerVideoUrl,
|
||||
tracks: _tracks!,
|
||||
recommendedService: widget.extensionId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionArtistScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const ExtensionArtistScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionArtistScreen> createState() =>
|
||||
_ExtensionArtistScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
|
||||
with _RouteSettled<ExtensionArtistScreen> {
|
||||
List<ArtistAlbum>? _albums;
|
||||
List<Track>? _topTracks;
|
||||
String? _headerImageUrl;
|
||||
String? _headerVideoUrl;
|
||||
String? _headerLogoUrl;
|
||||
List<ArtistConcert> _concerts = const [];
|
||||
String? _albumsNext;
|
||||
int? _monthlyListeners;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchArtist();
|
||||
}
|
||||
|
||||
Future<void> _fetchArtist() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await runExtensionOperationWithVerificationRetry(
|
||||
extensionId: widget.extensionId,
|
||||
browserMode: ref
|
||||
.read(settingsProvider)
|
||||
.extensionVerificationBrowserMode,
|
||||
operation: () => PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'artist',
|
||||
widget.artistId,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final artistInfo =
|
||||
result['artist_info'] as Map<String, dynamic>? ?? result;
|
||||
final albumList = result['albums'] as List<dynamic>?;
|
||||
final albums =
|
||||
albumList
|
||||
?.map((a) => _parseAlbum(a as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final topTracksList = result['top_tracks'] as List<dynamic>?;
|
||||
List<Track>? topTracks;
|
||||
if (topTracksList != null && topTracksList.isNotEmpty) {
|
||||
topTracks = topTracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
final headerImage =
|
||||
artistInfo['images'] as String? ??
|
||||
artistInfo['header_image'] as String? ??
|
||||
artistInfo['cover_url'] as String? ??
|
||||
result['header_image'] as String?;
|
||||
final headerVideo =
|
||||
artistInfo['header_video'] as String? ??
|
||||
result['header_video'] as String?;
|
||||
final listeners =
|
||||
artistInfo['listeners'] as int? ?? result['listeners'] as int?;
|
||||
final headerLogo = normalizeRemoteHttpUrl(
|
||||
(artistInfo['header_logo'] ?? result['header_logo'])?.toString(),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_albums = albums;
|
||||
_topTracks = topTracks;
|
||||
_headerImageUrl = headerImage;
|
||||
_headerVideoUrl = headerVideo;
|
||||
_headerLogoUrl = headerLogo;
|
||||
_concerts = ArtistConcert.parseList(
|
||||
artistInfo['concerts'] ?? result['concerts'],
|
||||
);
|
||||
_albumsNext = artistInfo['albums_next'] as String?;
|
||||
_monthlyListeners = listeners;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = context.l10n.snackbarError(context.friendlyError(e));
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ArtistAlbum _parseAlbum(Map<String, dynamic> data) {
|
||||
return ArtistAlbum(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artists: (data['artists'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: int.tryParse(data['total_tracks']?.toString() ?? '') ?? 0,
|
||||
coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
|
||||
albumType: (data['album_type'] ?? 'album').toString(),
|
||||
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final base = Track.fromBackendMap(data);
|
||||
return base.copyWith(
|
||||
id: (data['id'] ?? data['spotify_id'] ?? '').toString(),
|
||||
artistId: base.artistId ?? widget.artistId,
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
source: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null) {
|
||||
return SkeletonCrossfade(
|
||||
child: _LoadingOrErrorScaffold(
|
||||
title: widget.artistName,
|
||||
isLoading: false,
|
||||
error: _error,
|
||||
loadingBody: const SizedBox.shrink(),
|
||||
onRetry: _fetchArtist,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading || !routeSettled) {
|
||||
return SkeletonCrossfade(
|
||||
child: _ArtistLoadingScaffold(
|
||||
artistName: widget.artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SkeletonCrossfade(
|
||||
child: ArtistScreen(
|
||||
artistId: widget.artistId,
|
||||
artistName: widget.artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
headerImageUrl: _headerImageUrl,
|
||||
headerVideoUrl: _headerVideoUrl,
|
||||
headerLogoUrl: _headerLogoUrl,
|
||||
concerts: _concerts,
|
||||
albumsNext: _albumsNext,
|
||||
monthlyListeners: _monthlyListeners,
|
||||
albums: _albums,
|
||||
topTracks: _topTracks,
|
||||
extensionId: widget.extensionId, // Skip Spotify/Deezer fetch
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ import 'package:spotiflac_android/widgets/app_menu_button.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
@@ -33,7 +31,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/artist_concert.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
@@ -55,20 +52,16 @@ import 'package:spotiflac_android/services/music_player_service.dart'
|
||||
show PlayableMedia;
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/extension_collection_screens.dart';
|
||||
import 'package:spotiflac_android/screens/home_search_logic.dart';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_download_service.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/editorial_notes.dart';
|
||||
import 'package:spotiflac_android/utils/home_greeting.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
@@ -87,6 +80,8 @@ import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
|
||||
import 'package:spotiflac_android/widgets/profile_avatar.dart';
|
||||
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
|
||||
|
||||
export 'package:spotiflac_android/screens/extension_collection_screens.dart';
|
||||
|
||||
part 'home_tab_helpers.dart';
|
||||
part 'home_tab_explore.dart';
|
||||
part 'home_tab_recent.dart';
|
||||
|
||||
@@ -641,861 +641,6 @@ class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy `duration_ms` parsing used by extension album/playlist/artist track
|
||||
/// parsing, kept separate from [extractDurationMs] which also falls back to
|
||||
/// a `duration` (seconds) field these call sites intentionally ignore.
|
||||
int _legacyTrackDurationMs(Map<String, dynamic> data) {
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) return durationValue;
|
||||
if (durationValue is double) return durationValue.toInt();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Prefers the track's own cover, falling back to the parent
|
||||
/// album/playlist cover, without URL validation.
|
||||
String? _resolveTrackCoverUrl(String? trackCover, String? fallbackCover) {
|
||||
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
|
||||
return fallbackCover;
|
||||
}
|
||||
|
||||
/// Shared loading/error+retry scaffold for extension album/playlist/artist
|
||||
/// detail screens.
|
||||
class _LoadingOrErrorScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final Widget loadingBody;
|
||||
final VoidCallback onRetry;
|
||||
final bool collectionHeader;
|
||||
final String? coverUrl;
|
||||
|
||||
const _LoadingOrErrorScaffold({
|
||||
required this.title,
|
||||
required this.isLoading,
|
||||
required this.error,
|
||||
required this.loadingBody,
|
||||
required this.onRetry,
|
||||
this.collectionHeader = false,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading) {
|
||||
if (context.isMornye && collectionHeader) {
|
||||
return MornyeArtistSurface(
|
||||
imageSource: coverUrl,
|
||||
neutralActions: true,
|
||||
child: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leadingWidth: 68,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: loadingBody,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!context.isMornye && collectionHeader) {
|
||||
// The real header with the title and cover already known; only the
|
||||
// rows that wait for the tracks are skeletons.
|
||||
final cover = coverUrl;
|
||||
final hasCover = cover != null && cover.isNotEmpty;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
AlbumDetailHeader(
|
||||
title: title,
|
||||
expandedHeight: collectionHeaderExpandedHeight(context),
|
||||
showTitleInAppBar: false,
|
||||
paletteSource: hasCover ? cover : null,
|
||||
blurAndScrimBackground: hasCover,
|
||||
background: hasCover
|
||||
? CachedCoverImage(imageUrl: cover, fit: BoxFit.cover)
|
||||
: ColoredBox(color: colors.surfaceContainerHighest),
|
||||
coverBuilder: (context, size) => hasCover
|
||||
? CachedCoverImage(
|
||||
imageUrl: cover,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: ShimmerLoading(
|
||||
child: SkeletonBox(
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
subtitle: CollectionHeaderSkeletonRows.subtitle(),
|
||||
meta: CollectionHeaderSkeletonRows.meta(),
|
||||
actions: CollectionHeaderSkeletonRows.actions(context),
|
||||
),
|
||||
SliverToBoxAdapter(child: loadingBody),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
leadingWidth: context.isMornye ? 68 : null,
|
||||
leading: context.isMornye
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: loadingBody,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
context.friendlyError(error),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: onRetry,
|
||||
child: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Defers swapping the loading scaffold for the loaded screen until the push
|
||||
/// transition (and its Hero flight) has finished. Swapping mid-flight puts a
|
||||
/// second hero with the same tag on screen — the flight doesn't adopt it, so
|
||||
/// the user sees a static cover next to the flying one.
|
||||
mixin _RouteSettled<T extends StatefulWidget> on State<T> {
|
||||
bool _routeSettled = false;
|
||||
Animation<double>? _routeAnimation;
|
||||
|
||||
bool get routeSettled => _routeSettled;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_routeSettled) return;
|
||||
final animation = ModalRoute.of(context)?.animation;
|
||||
if (animation == null || animation.isCompleted) {
|
||||
_routeSettled = true;
|
||||
return;
|
||||
}
|
||||
if (!identical(animation, _routeAnimation)) {
|
||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||
_routeAnimation = animation;
|
||||
animation.addStatusListener(_onRouteStatus);
|
||||
}
|
||||
}
|
||||
|
||||
void _onRouteStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||
_routeAnimation = null;
|
||||
setState(() => _routeSettled = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading state for [ExtensionArtistScreen]: the real full-bleed header with
|
||||
/// the cover and artist name above the discography skeleton.
|
||||
class _ArtistLoadingScaffold extends StatelessWidget {
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const _ArtistLoadingScaffold({
|
||||
required this.artistName,
|
||||
required this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.isMornye) {
|
||||
return MornyeArtistSurface(
|
||||
imageSource: coverUrl,
|
||||
child: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
...MornyeArtistHeader(
|
||||
name: artistName,
|
||||
showTitle: false,
|
||||
artwork: coverUrl != null && coverUrl!.isNotEmpty
|
||||
? CachedCoverImage(
|
||||
imageUrl: coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
memCacheWidth: 800,
|
||||
)
|
||||
: const ShimmerLoading(
|
||||
child: SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
actions: const [
|
||||
ShimmerLoading(child: ArtistHeaderActionsSkeleton()),
|
||||
],
|
||||
).buildSlivers(context),
|
||||
const SliverToBoxAdapter(
|
||||
child: ArtistScreenSkeleton(showCoverHeader: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final edgeInset = detailHeaderEdgeInset(context);
|
||||
final url = coverUrl;
|
||||
final hasImage =
|
||||
url != null &&
|
||||
url.isNotEmpty &&
|
||||
Uri.tryParse(url)?.hasAuthority == true;
|
||||
|
||||
final Widget image = hasImage
|
||||
? CachedCoverImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
memCacheWidth: 800,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 420,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
image,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.3),
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
isDark
|
||||
? colorScheme.surface
|
||||
: Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
stops: const [0.0, 0.5, 0.75, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16 + edgeInset,
|
||||
right: 16 + edgeInset,
|
||||
bottom: 16,
|
||||
child: Text(
|
||||
artistName,
|
||||
style: Theme.of(context).textTheme.headlineLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
leadingWidth: kToolbarHeight + edgeInset,
|
||||
leading: Padding(
|
||||
padding: EdgeInsets.only(left: edgeInset),
|
||||
// Centered so the tight leading slot cannot stretch it.
|
||||
child: Center(
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: ArtistScreenSkeleton(showCoverHeader: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final String? initialAlbumType;
|
||||
final int? initialTotalTracks;
|
||||
|
||||
const ExtensionAlbumScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.albumId,
|
||||
required this.albumName,
|
||||
this.coverUrl,
|
||||
this.initialAlbumType,
|
||||
this.initialTotalTracks,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionAlbumScreen> createState() =>
|
||||
_ExtensionAlbumScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
String? _albumName;
|
||||
String? _description;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String? _artistId;
|
||||
String? _artistName;
|
||||
String? _albumType;
|
||||
int? _albumTotalTracks;
|
||||
String? _headerVideoUrl;
|
||||
String? _headerImageUrl;
|
||||
List<String> _audioTraits = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_albumType = normalizeOptionalString(widget.initialAlbumType);
|
||||
_albumTotalTracks = widget.initialTotalTracks;
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await runExtensionOperationWithVerificationRetry(
|
||||
extensionId: widget.extensionId,
|
||||
browserMode: ref
|
||||
.read(settingsProvider)
|
||||
.extensionVerificationBrowserMode,
|
||||
operation: () => PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'album',
|
||||
widget.albumId,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final albumInfo = result['album_info'] as Map<String, dynamic>? ?? result;
|
||||
final trackList =
|
||||
result['track_list'] as List<dynamic>? ??
|
||||
result['tracks'] as List<dynamic>?;
|
||||
if (trackList == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorNoTracksFound;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final artistId = (albumInfo['artist_id'] ?? albumInfo['artistId'])
|
||||
?.toString();
|
||||
final albumName =
|
||||
normalizeOptionalString(albumInfo['name']?.toString()) ??
|
||||
widget.albumName;
|
||||
final artistName = (albumInfo['artists'] ?? albumInfo['artist'])
|
||||
?.toString();
|
||||
final albumType =
|
||||
normalizeOptionalString(albumInfo['album_type']?.toString()) ??
|
||||
_albumType;
|
||||
final totalTracks =
|
||||
albumInfo['total_tracks'] as int? ?? _albumTotalTracks;
|
||||
final headerVideo = albumInfo['header_video']?.toString();
|
||||
final headerImage = albumInfo['header_image']?.toString();
|
||||
final audioTraits = (albumInfo['audio_traits'] as List?)
|
||||
?.map((e) => e.toString())
|
||||
.toList();
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumNameFallback: albumName,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_albumName = albumName;
|
||||
_description = albumDescriptionFromMetadata(albumInfo);
|
||||
_artistId = artistId;
|
||||
_artistName = artistName;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
|
||||
? headerVideo
|
||||
: _headerVideoUrl;
|
||||
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
|
||||
? headerImage
|
||||
: _headerImageUrl;
|
||||
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
|
||||
? audioTraits
|
||||
: _audioTraits;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = context.l10n.snackbarError(context.friendlyError(e));
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(
|
||||
Map<String, dynamic> data, {
|
||||
String? albumNameFallback,
|
||||
String? albumTypeFallback,
|
||||
int? totalTracksFallback,
|
||||
}) {
|
||||
final base = Track.fromBackendMap(data, source: widget.extensionId);
|
||||
return base.copyWith(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
albumName:
|
||||
normalizeOptionalString(data['album_name']?.toString()) ??
|
||||
albumNameFallback ??
|
||||
widget.albumName,
|
||||
albumArtist: normalizeOptionalString(data['album_artist']?.toString()),
|
||||
artistId: base.artistId ?? _artistId,
|
||||
albumId: base.albumId ?? widget.albumId,
|
||||
coverUrl: _resolveTrackCoverUrl(
|
||||
data['cover_url']?.toString(),
|
||||
widget.coverUrl,
|
||||
),
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
albumType: base.albumType ?? albumTypeFallback ?? _albumType,
|
||||
totalTracks: base.totalTracks ?? totalTracksFallback ?? _albumTotalTracks,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading || _error != null) {
|
||||
return _LoadingOrErrorScaffold(
|
||||
title: widget.albumName,
|
||||
collectionHeader: true,
|
||||
coverUrl: widget.coverUrl,
|
||||
isLoading: _isLoading,
|
||||
error: _error,
|
||||
loadingBody: const AlbumTrackListSkeleton(
|
||||
itemCount: 10,
|
||||
showCoverHeader: true,
|
||||
),
|
||||
onRetry: _fetchTracks,
|
||||
);
|
||||
}
|
||||
|
||||
return AlbumScreen(
|
||||
albumId: widget.albumId,
|
||||
albumName: _albumName ?? widget.albumName,
|
||||
description: _description,
|
||||
coverUrl: widget.coverUrl,
|
||||
headerVideoUrl: _headerVideoUrl,
|
||||
headerImageUrl: _headerImageUrl,
|
||||
audioTraits: _audioTraits,
|
||||
tracks: _tracks,
|
||||
extensionId: widget.extensionId,
|
||||
artistId: _artistId,
|
||||
artistName: _artistName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen for viewing extension playlist with track fetching
|
||||
class ExtensionPlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String playlistId;
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const ExtensionPlaylistScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.playlistId,
|
||||
required this.playlistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionPlaylistScreen> createState() =>
|
||||
_ExtensionPlaylistScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionPlaylistScreenState
|
||||
extends ConsumerState<ExtensionPlaylistScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String? _headerVideoUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await runExtensionOperationWithVerificationRetry(
|
||||
extensionId: widget.extensionId,
|
||||
browserMode: ref
|
||||
.read(settingsProvider)
|
||||
.extensionVerificationBrowserMode,
|
||||
operation: () => PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'playlist',
|
||||
widget.playlistId,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final trackList =
|
||||
result['track_list'] as List<dynamic>? ??
|
||||
result['tracks'] as List<dynamic>?;
|
||||
if (trackList == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorNoTracksFound;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
|
||||
final headerVideo = playlistInfo?['header_video']?.toString();
|
||||
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
|
||||
? headerVideo
|
||||
: _headerVideoUrl;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = context.l10n.snackbarError(context.friendlyError(e));
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final base = Track.fromBackendMap(
|
||||
data,
|
||||
source: widget.extensionId,
|
||||
playlistName: widget.playlistName,
|
||||
);
|
||||
return base.copyWith(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
coverUrl: _resolveTrackCoverUrl(
|
||||
data['cover_url']?.toString(),
|
||||
widget.coverUrl,
|
||||
),
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading || _error != null) {
|
||||
return SkeletonCrossfade(
|
||||
child: _LoadingOrErrorScaffold(
|
||||
title: widget.playlistName,
|
||||
collectionHeader: true,
|
||||
coverUrl: widget.coverUrl,
|
||||
isLoading: _isLoading,
|
||||
error: _error,
|
||||
loadingBody: const TrackListSkeleton(
|
||||
itemCount: 8,
|
||||
showCoverHeader: true,
|
||||
),
|
||||
onRetry: _fetchTracks,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SkeletonCrossfade(
|
||||
child: PlaylistScreen(
|
||||
playlistName: widget.playlistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
headerVideoUrl: _headerVideoUrl,
|
||||
tracks: _tracks!,
|
||||
recommendedService: widget.extensionId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionArtistScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const ExtensionArtistScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionArtistScreen> createState() =>
|
||||
_ExtensionArtistScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
|
||||
with _RouteSettled<ExtensionArtistScreen> {
|
||||
List<ArtistAlbum>? _albums;
|
||||
List<Track>? _topTracks;
|
||||
String? _headerImageUrl;
|
||||
String? _headerVideoUrl;
|
||||
String? _headerLogoUrl;
|
||||
List<ArtistConcert> _concerts = const [];
|
||||
String? _albumsNext;
|
||||
int? _monthlyListeners;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchArtist();
|
||||
}
|
||||
|
||||
Future<void> _fetchArtist() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await runExtensionOperationWithVerificationRetry(
|
||||
extensionId: widget.extensionId,
|
||||
browserMode: ref
|
||||
.read(settingsProvider)
|
||||
.extensionVerificationBrowserMode,
|
||||
operation: () => PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'artist',
|
||||
widget.artistId,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final artistInfo =
|
||||
result['artist_info'] as Map<String, dynamic>? ?? result;
|
||||
final albumList = result['albums'] as List<dynamic>?;
|
||||
final albums =
|
||||
albumList
|
||||
?.map((a) => _parseAlbum(a as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final topTracksList = result['top_tracks'] as List<dynamic>?;
|
||||
List<Track>? topTracks;
|
||||
if (topTracksList != null && topTracksList.isNotEmpty) {
|
||||
topTracks = topTracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
final headerImage =
|
||||
artistInfo['images'] as String? ??
|
||||
artistInfo['header_image'] as String? ??
|
||||
artistInfo['cover_url'] as String? ??
|
||||
result['header_image'] as String?;
|
||||
final headerVideo =
|
||||
artistInfo['header_video'] as String? ??
|
||||
result['header_video'] as String?;
|
||||
final listeners =
|
||||
artistInfo['listeners'] as int? ?? result['listeners'] as int?;
|
||||
final headerLogo = normalizeRemoteHttpUrl(
|
||||
(artistInfo['header_logo'] ?? result['header_logo'])?.toString(),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_albums = albums;
|
||||
_topTracks = topTracks;
|
||||
_headerImageUrl = headerImage;
|
||||
_headerVideoUrl = headerVideo;
|
||||
_headerLogoUrl = headerLogo;
|
||||
_concerts = ArtistConcert.parseList(
|
||||
artistInfo['concerts'] ?? result['concerts'],
|
||||
);
|
||||
_albumsNext = artistInfo['albums_next'] as String?;
|
||||
_monthlyListeners = listeners;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = context.l10n.snackbarError(context.friendlyError(e));
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ArtistAlbum _parseAlbum(Map<String, dynamic> data) {
|
||||
return ArtistAlbum(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artists: (data['artists'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: int.tryParse(data['total_tracks']?.toString() ?? '') ?? 0,
|
||||
coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
|
||||
albumType: (data['album_type'] ?? 'album').toString(),
|
||||
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final base = Track.fromBackendMap(data);
|
||||
return base.copyWith(
|
||||
id: (data['id'] ?? data['spotify_id'] ?? '').toString(),
|
||||
artistId: base.artistId ?? widget.artistId,
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
source: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null) {
|
||||
return SkeletonCrossfade(
|
||||
child: _LoadingOrErrorScaffold(
|
||||
title: widget.artistName,
|
||||
isLoading: false,
|
||||
error: _error,
|
||||
loadingBody: const SizedBox.shrink(),
|
||||
onRetry: _fetchArtist,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading || !routeSettled) {
|
||||
return SkeletonCrossfade(
|
||||
child: _ArtistLoadingScaffold(
|
||||
artistName: widget.artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SkeletonCrossfade(
|
||||
child: ArtistScreen(
|
||||
artistId: widget.artistId,
|
||||
artistName: widget.artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
headerImageUrl: _headerImageUrl,
|
||||
headerVideoUrl: _headerVideoUrl,
|
||||
headerLogoUrl: _headerLogoUrl,
|
||||
concerts: _concerts,
|
||||
albumsNext: _albumsNext,
|
||||
monthlyListeners: _monthlyListeners,
|
||||
albums: _albums,
|
||||
topTracks: _topTracks,
|
||||
extensionId: widget.extensionId, // Skip Spotify/Deezer fetch
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Swipeable Quick Picks widget with page indicator
|
||||
class _QuickPicksPageView extends StatefulWidget {
|
||||
final ExploreSection section;
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
import 'package:spotiflac_android/screens/extension_collection_screens.dart'
|
||||
show ExtensionArtistScreen, ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
Reference in new issue
Block a user