mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
feat(albums): show formatted editorial descriptions
This commit is contained in:
1 parent
0e1881d837
commit
7d7716cd6f
13 files changed
+465
-25
No files matched your search
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/models/artist_concert.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/editorial_notes.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
@@ -19,6 +20,7 @@ class TrackState {
|
||||
final String? error;
|
||||
final String? albumId;
|
||||
final String? albumName;
|
||||
final String? albumDescription;
|
||||
final String? playlistName;
|
||||
final String? playlistId;
|
||||
final String? artistId;
|
||||
@@ -44,6 +46,7 @@ class TrackState {
|
||||
this.error,
|
||||
this.albumId,
|
||||
this.albumName,
|
||||
this.albumDescription,
|
||||
this.playlistName,
|
||||
this.playlistId,
|
||||
this.artistId,
|
||||
@@ -72,6 +75,7 @@ class TrackState {
|
||||
String? error,
|
||||
String? albumId,
|
||||
String? albumName,
|
||||
String? albumDescription,
|
||||
String? playlistName,
|
||||
String? playlistId,
|
||||
String? artistId,
|
||||
@@ -99,6 +103,7 @@ class TrackState {
|
||||
error: error,
|
||||
albumId: albumId ?? this.albumId,
|
||||
albumName: albumName ?? this.albumName,
|
||||
albumDescription: albumDescription ?? this.albumDescription,
|
||||
playlistName: playlistName ?? this.playlistName,
|
||||
playlistId: playlistId ?? this.playlistId,
|
||||
artistId: artistId ?? this.artistId,
|
||||
@@ -262,6 +267,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
albumName:
|
||||
collectionName ??
|
||||
(result['album'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
albumDescription: type == 'album'
|
||||
? albumDescriptionFromMetadata(
|
||||
result['album'] as Map<String, dynamic>?,
|
||||
) ??
|
||||
albumDescriptionFromMetadata(result)
|
||||
: null,
|
||||
playlistName: type == 'playlist' ? collectionName : null,
|
||||
playlistId: type == 'playlist' ? result['id'] as String? : null,
|
||||
coverUrl: normalizeCoverReference(result['cover_url']?.toString()),
|
||||
|
||||
@@ -20,6 +20,8 @@ import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
|
||||
import 'package:spotiflac_android/widgets/error_card.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/widgets/album_description.dart';
|
||||
import 'package:spotiflac_android/utils/editorial_notes.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
||||
import 'package:spotiflac_android/utils/ttl_cache.dart';
|
||||
@@ -37,15 +39,16 @@ import 'package:spotiflac_android/widgets/downloadable_cover.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
|
||||
|
||||
class _AlbumCache {
|
||||
static final _cache = TtlCache<List<Track>>(
|
||||
static final _cache = TtlCache<({List<Track> tracks, String? description})>(
|
||||
const Duration(minutes: 10),
|
||||
maxEntries: 40,
|
||||
);
|
||||
|
||||
static List<Track>? get(String albumId) => _cache.get(albumId);
|
||||
static ({List<Track> tracks, String? description})? get(String key) =>
|
||||
_cache.get(key);
|
||||
|
||||
static void set(String albumId, List<Track> tracks) =>
|
||||
_cache.set(albumId, tracks);
|
||||
static void set(String key, List<Track> tracks, String? description) =>
|
||||
_cache.set(key, (tracks: tracks, description: description));
|
||||
}
|
||||
|
||||
class AlbumScreen extends ConsumerStatefulWidget {
|
||||
@@ -59,6 +62,7 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
||||
final String? extensionId;
|
||||
final String? artistId;
|
||||
final String? artistName;
|
||||
final String? description;
|
||||
|
||||
const AlbumScreen({
|
||||
super.key,
|
||||
@@ -72,6 +76,7 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
||||
this.extensionId,
|
||||
this.artistId,
|
||||
this.artistName,
|
||||
this.description,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -92,6 +97,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
String? _headerVideoUrl;
|
||||
String? _headerImageUrl;
|
||||
List<String> _audioTraits = const [];
|
||||
String? _description;
|
||||
|
||||
String get _cacheKey =>
|
||||
'${_effectiveMetadataProviderIdFromAlbumId()}:${widget.albumId}';
|
||||
|
||||
String _effectiveMetadataProviderIdFromAlbumId() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
@@ -123,10 +132,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
);
|
||||
});
|
||||
|
||||
final cached = _AlbumCache.get(_cacheKey);
|
||||
_description = widget.description ?? cached?.description;
|
||||
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
||||
_tracks = widget.tracks;
|
||||
} else {
|
||||
_tracks = _AlbumCache.get(widget.albumId);
|
||||
_tracks = cached?.tracks;
|
||||
}
|
||||
_artistId = widget.artistId;
|
||||
_albumType = _tracks?.firstOrNull?.albumType;
|
||||
@@ -154,8 +165,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
_metadataResourceId(directProviderId),
|
||||
);
|
||||
_applyAlbumMetadata(
|
||||
metadata['track_list'] as List<dynamic>,
|
||||
metadata['album_info'] as Map<String, dynamic>?,
|
||||
(metadata['track_list'] ?? metadata['tracks']) as List<dynamic>,
|
||||
metadata['album_info'] as Map<String, dynamic>? ?? metadata,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
@@ -217,11 +228,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
)
|
||||
.toList();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
final description =
|
||||
albumDescriptionFromMetadata(albumInfo) ??
|
||||
albumDescriptionFromMetadata(fallbackSource);
|
||||
_AlbumCache.set(_cacheKey, tracks, description);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_description = description;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
@@ -384,6 +399,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
),
|
||||
appBar: _buildAppBar(context, colorScheme, pageBackgroundColor),
|
||||
slivers: [
|
||||
if (_description != null)
|
||||
SliverToBoxAdapter(
|
||||
child: AlbumDescription(
|
||||
title: widget.albumName,
|
||||
description: _description!,
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
|
||||
@@ -41,6 +41,7 @@ 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/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -627,6 +628,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
builder: (context) => AlbumScreen(
|
||||
albumId: trackState.albumId!,
|
||||
albumName: trackState.albumName!,
|
||||
description: trackState.albumDescription,
|
||||
coverUrl: trackState.coverUrl,
|
||||
tracks: trackState.tracks,
|
||||
extensionId: extensionId,
|
||||
|
||||
@@ -1014,6 +1014,7 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
String? _albumName;
|
||||
String? _description;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String? _artistId;
|
||||
@@ -1095,6 +1096,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_albumName = albumName;
|
||||
_description = albumDescriptionFromMetadata(albumInfo);
|
||||
_artistId = artistId;
|
||||
_artistName = artistName;
|
||||
_albumType = albumType;
|
||||
@@ -1165,6 +1167,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
return AlbumScreen(
|
||||
albumId: widget.albumId,
|
||||
albumName: _albumName ?? widget.albumName,
|
||||
description: _description,
|
||||
coverUrl: widget.coverUrl,
|
||||
headerVideoUrl: _headerVideoUrl,
|
||||
headerImageUrl: _headerImageUrl,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
|
||||
/// Editorial text is display-only. Never load embedded media or execute markup.
|
||||
DocumentFragment parseEditorialNotes(String value) {
|
||||
final fragment = html.parseFragment(value);
|
||||
for (final element in fragment.querySelectorAll(
|
||||
'script, style, iframe, object, template',
|
||||
)) {
|
||||
element.remove();
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
String? albumDescriptionFromMetadata(Map<String, dynamic>? metadata) {
|
||||
final notes =
|
||||
metadata?['editorial_notes'] ??
|
||||
metadata?['editorialNotes'] ??
|
||||
metadata?['description'];
|
||||
final candidates = notes is Map
|
||||
? [notes['standard'], notes['short']]
|
||||
: [notes];
|
||||
for (final candidate in candidates) {
|
||||
if (candidate is String &&
|
||||
(parseEditorialNotes(candidate).text ?? '').trim().isNotEmpty) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/utils/editorial_notes.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
|
||||
/// A short editorial preview below the album controls, with a full reading sheet.
|
||||
class AlbumDescription extends StatelessWidget {
|
||||
const AlbumDescription({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
TextSpan _text() {
|
||||
final fragment = parseEditorialNotes(description);
|
||||
final formatted = fragment.children.isNotEmpty;
|
||||
List<InlineSpan> spans(List<dom.Node> nodes) => [
|
||||
for (final node in nodes)
|
||||
if (node is dom.Text)
|
||||
TextSpan(
|
||||
text: formatted
|
||||
// Catalog notes can mix inline markup with literal paragraph
|
||||
// breaks. Only collapse horizontal whitespace.
|
||||
? node.data.replaceAll(RegExp(r'[\t\f\v ]+'), ' ')
|
||||
: node.data,
|
||||
)
|
||||
else if (node is dom.Element)
|
||||
if (node.localName == 'br')
|
||||
const TextSpan(text: '\n')
|
||||
else ...[
|
||||
TextSpan(
|
||||
style: switch (node.localName) {
|
||||
'b' || 'strong' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'i' || 'em' => const TextStyle(fontStyle: FontStyle.italic),
|
||||
_ => null,
|
||||
},
|
||||
children: spans(node.nodes),
|
||||
),
|
||||
if (['p', 'div'].contains(node.localName))
|
||||
const TextSpan(text: '\n\n'),
|
||||
],
|
||||
];
|
||||
return TextSpan(children: spans(fragment.nodes));
|
||||
}
|
||||
|
||||
void _showFullDescription(BuildContext context, TextSpan text) {
|
||||
showAppModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.9,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
HeaderCircleButton(
|
||||
buttonSize: 48,
|
||||
icon: context.isMornye
|
||||
? CupertinoIcons.xmark
|
||||
: Icons.close,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).closeButtonTooltip,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
|
||||
child: SelectionArea(
|
||||
child: Text.rich(
|
||||
text,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontSize: context.isMornye ? 18 : 16,
|
||||
height: 1.45,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = _text();
|
||||
if (text.toPlainText().trim().isEmpty) return const SizedBox.shrink();
|
||||
final theme = Theme.of(context);
|
||||
final more = MaterialLocalizations.of(context).moreButtonTooltip;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => _showFullDescription(context, text),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontSize: context.isMornye ? 17 : 16,
|
||||
height: 1.4,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.isMornye ? more.toUpperCase() : more,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Divider(color: theme.colorScheme.onSurface.withValues(alpha: 0.15)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user