feat(albums): show formatted editorial descriptions

This commit is contained in:
zarzet committed 2026-09-26 03:50:37 +07:00
1 parent 0e1881d837
commit 7d7716cd6f
13 files changed
+465 -25

No files matched your search

+11
View File
@@ -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()),
+30 -8
View File
@@ -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(
+2
View File
@@ -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,
+3
View File
@@ -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,
+30
View File
@@ -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;
}
+164
View File
@@ -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)),
],
),
);
}
}