mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(albums): show formatted editorial descriptions
This commit is contained in:
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -669,7 +669,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
|
||||
|
||||
@@ -71,6 +71,7 @@ dependencies:
|
||||
video_player: ^2.14.0
|
||||
|
||||
xml: ^7.0.1
|
||||
html: ^0.15.7
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: ^22.3.0
|
||||
|
||||
@@ -64,6 +64,11 @@ impl Backend {
|
||||
],
|
||||
);
|
||||
info.insert("audio_traits".into(), value["audio_traits"].clone());
|
||||
for key in ["editorial_notes", "description"] {
|
||||
if let Some(notes) = value.get(key) {
|
||||
info.insert(key.into(), notes.clone());
|
||||
}
|
||||
}
|
||||
info.insert(
|
||||
"total_tracks".into(),
|
||||
json!(value["total_tracks"].as_i64().unwrap_or_default()),
|
||||
@@ -386,6 +391,11 @@ fn album(value: &Value, full: bool) -> Value {
|
||||
&["artist_id", "header_image", "header_video"],
|
||||
));
|
||||
result.insert("audio_traits".into(), json!(array(value, "audio_traits")));
|
||||
for key in ["editorial_notes", "description"] {
|
||||
if let Some(notes) = value.get(key) {
|
||||
result.insert(key.into(), notes.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
result.into()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ function collection(id) {
|
||||
const count = Number(id);
|
||||
return {id, name: "Album 音楽 🎵", artists: "Artist Café", provider_id: "supplied",
|
||||
cover_url: "https://example.invalid/cover.jpg", total_tracks: count,
|
||||
editorialNotes: {standard: "<p>A <i>new</i> direction & sound.</p>", short: "A new direction."},
|
||||
description: "Collection notes",
|
||||
tracks: Array.from({length: count}, (_, i) => ({id: "track-" + i,
|
||||
name: "歌 🎵 " + i, artists: "Artist Café", album_name: "Album 音楽 🎵",
|
||||
provider_id: "supplied-track", duration_ms: 123456, track_number: i + 1,
|
||||
@@ -24,7 +26,10 @@ function getArtist(id) {
|
||||
url: "https://example.invalid/events/1", detailId: "event-1"}],
|
||||
headerLogo: "https://example.invalid/logo.png", albumsNext: "artist-page-2", albums: []};
|
||||
}
|
||||
function handleUrl() { return {type: "artist", artist: getArtist("artist-1")}; }
|
||||
function handleUrl(url) {
|
||||
if (url.includes("/album/")) return {type: "album", album: collection("1"), tracks: collection("1").tracks};
|
||||
return {type: "artist", artist: getArtist("artist-1")};
|
||||
}
|
||||
function getConcert(id) {
|
||||
return {id, artistName: "Example Artist", title: "Example Show", venue: "Example Hall",
|
||||
address: "123 Example Street", startAt: "2026-10-07T01:00:00Z",
|
||||
@@ -86,6 +91,31 @@ fn concert_details_preserve_generic_actions_and_set_list() {
|
||||
assert_eq!(detail["attribution"], "Example Events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_editorial_notes_survive_metadata_and_url_routes() {
|
||||
let (_root, backend) = fixture();
|
||||
let direct: Value = serde_json::from_str(
|
||||
&backend
|
||||
.get_provider_metadata_json(ID, "album", "1", &|| Ok(()))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let linked: Value = serde_json::from_str(
|
||||
&backend
|
||||
.handle_url_json("https://example.invalid/album/1")
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for album in [&direct["album_info"], &linked["album"]] {
|
||||
assert_eq!(
|
||||
album["editorial_notes"]["standard"],
|
||||
"<p>A <i>new</i> direction & sound.</p>"
|
||||
);
|
||||
assert_eq!(album["editorial_notes"]["short"], "A new direction.");
|
||||
assert_eq!(album["description"], "Collection notes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_envelopes_preserve_json_and_cancellation() {
|
||||
let value = json!({
|
||||
|
||||
@@ -160,7 +160,8 @@
|
||||
["cover_url","s","coverUrl","images"],["header_image","s","headerImage"],
|
||||
["header_video","s","headerVideo"],["release_date","s","releaseDate"],
|
||||
["total_tracks","i","totalTracks"],["album_type","s","albumType"],
|
||||
["audio_traits","a","audioTraits"],["provider_id","s","providerId"]
|
||||
["audio_traits","a","audioTraits"],["provider_id","s","providerId"],
|
||||
["editorial_notes","M","editorialNotes"],["description","s"]
|
||||
], ["id","name","artists","total_tracks","provider_id"]);
|
||||
result.tracks = tracks;
|
||||
if (!trim(result.artists)) {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/utils/editorial_notes.dart';
|
||||
import 'package:spotiflac_android/widgets/album_description.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'editorial notes prefer full text and fall back to a usable summary',
|
||||
() {
|
||||
expect(
|
||||
albumDescriptionFromMetadata({
|
||||
'editorial_notes': {
|
||||
'standard': '<p>Full notes.</p>',
|
||||
'short': 'Short notes.',
|
||||
},
|
||||
}),
|
||||
'<p>Full notes.</p>',
|
||||
);
|
||||
expect(
|
||||
albumDescriptionFromMetadata({
|
||||
'editorial_notes': {
|
||||
'standard': '<p> </p>',
|
||||
'short': 'Short notes.',
|
||||
},
|
||||
}),
|
||||
'Short notes.',
|
||||
);
|
||||
expect(
|
||||
albumDescriptionFromMetadata({'description': 'Plain notes.'}),
|
||||
'Plain notes.',
|
||||
);
|
||||
expect(albumDescriptionFromMetadata({'editorial_notes': null}), isNull);
|
||||
expect(albumDescriptionFromMetadata({'editorial_notes': 42}), isNull);
|
||||
expect(
|
||||
albumDescriptionFromMetadata({
|
||||
'editorial_notes': {'standard': '<script>hidden</script>'},
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (final style in ['mornye-light', 'mornye-dark', 'material']) {
|
||||
testWidgets('album description opens a formatted reading sheet ($style)', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(393, 700));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
final mornye = style.startsWith('mornye');
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: mornye
|
||||
? MornyeTheme.build(
|
||||
style.endsWith('light')
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
)
|
||||
: ThemeData(),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Navigator(
|
||||
onGenerateRoute: (_) => MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
body: AlbumDescription(
|
||||
title: 'Example Album',
|
||||
description:
|
||||
'A <i>new direction</i> & a <b>bold sound</b>.\n\n'
|
||||
'A second paragraph.\nOne line break remains.\n\n'
|
||||
'${List.filled(12, '<p>The artists explore melodies and new ideas in these songs.</p>').join()}'
|
||||
'<p>Final paragraph.</p><script>hidden script</script>',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final preview = tester.widget<Text>(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is Text && widget.textSpan != null,
|
||||
),
|
||||
);
|
||||
expect(preview.maxLines, 2);
|
||||
expect(
|
||||
preview.textSpan!.toPlainText(),
|
||||
contains('new direction & a bold sound.\n\n'),
|
||||
);
|
||||
expect(preview.textSpan!.toPlainText(), isNot(contains('hidden script')));
|
||||
expect(
|
||||
preview.textSpan!.toPlainText(),
|
||||
contains('sound.\n\nA second paragraph.\nOne line break remains.\n\n'),
|
||||
);
|
||||
|
||||
Iterable<TextSpan> spans(InlineSpan span) sync* {
|
||||
if (span is TextSpan) {
|
||||
yield span;
|
||||
for (final child in span.children ?? <InlineSpan>[]) {
|
||||
yield* spans(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
spans(preview.textSpan!).any(
|
||||
(span) =>
|
||||
span.style?.fontStyle == FontStyle.italic &&
|
||||
span.toPlainText() == 'new direction',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await tester.tap(find.text(mornye ? 'MORE' : 'More'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Example Album'), findsOneWidget);
|
||||
expect(find.byType(SelectionArea), findsOneWidget);
|
||||
expect(
|
||||
ModalRoute.of(tester.element(find.byType(SelectionArea)))!.navigator,
|
||||
tester.state<NavigatorState>(find.byType(Navigator).first),
|
||||
);
|
||||
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
|
||||
await tester.drag(
|
||||
find.byType(SingleChildScrollView),
|
||||
const Offset(0, -600),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final scroll = tester.state<ScrollableState>(
|
||||
find.descendant(
|
||||
of: find.byType(SingleChildScrollView),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
expect(scroll.position.pixels, greaterThan(0));
|
||||
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SelectionArea), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/album_description.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/track_list_tile.dart';
|
||||
|
||||
@@ -54,6 +55,10 @@ void main() {
|
||||
'name': fetchedName,
|
||||
'artists': 'Example Artist',
|
||||
'total_tracks': 1,
|
||||
'editorial_notes': {
|
||||
'standard': '<p>A <i>new direction</i> for the band.</p>',
|
||||
'short': 'A new direction.',
|
||||
},
|
||||
};
|
||||
final tracks = [
|
||||
{
|
||||
@@ -89,6 +94,10 @@ void main() {
|
||||
final expected = fetchedName.trim().isEmpty ? 'Album' : fetchedName;
|
||||
final album = tester.widget<AlbumScreen>(find.byType(AlbumScreen));
|
||||
expect(album.albumName, expected);
|
||||
expect(
|
||||
album.description,
|
||||
'<p>A <i>new direction</i> for the band.</p>',
|
||||
);
|
||||
expect(album.tracks!.single.albumName, expected);
|
||||
expect(find.text(expected), findsWidgets);
|
||||
expect(requests, 1);
|
||||
@@ -124,7 +133,7 @@ void main() {
|
||||
'audio_modes': 'DOLBY_ATMOS',
|
||||
'explicit': true,
|
||||
};
|
||||
var albumRequested = false;
|
||||
var albumRequests = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'getProviderMetadata') {
|
||||
@@ -133,7 +142,7 @@ void main() {
|
||||
'resource_type': 'album',
|
||||
'resource_id': 'metadata-album',
|
||||
});
|
||||
albumRequested = true;
|
||||
albumRequests++;
|
||||
return jsonEncode({
|
||||
'track_list': [payload],
|
||||
'album_info': {
|
||||
@@ -141,6 +150,7 @@ void main() {
|
||||
'total_tracks': 1,
|
||||
'album_type': 'album',
|
||||
'audio_traits': ['lossless', 'dolby_atmos'],
|
||||
'editorial_notes': {'standard': '<p>A new direction.</p>'},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -156,22 +166,22 @@ void main() {
|
||||
expect(searchTrack.label, 'Example Label');
|
||||
expect(searchTrack.copyright, 'Example Copyright');
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(Brightness.light),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const AlbumScreen(
|
||||
albumId: 'metadata-album',
|
||||
albumName: 'Example Album',
|
||||
extensionId: 'example-metadata',
|
||||
),
|
||||
final page = ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(Brightness.light),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const AlbumScreen(
|
||||
albumId: 'metadata-album',
|
||||
albumName: 'Example Album',
|
||||
extensionId: 'example-metadata',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(page);
|
||||
await tester.pumpAndSettle();
|
||||
expect(albumRequested, isTrue);
|
||||
expect(albumRequests, 1);
|
||||
expect(find.byType(AlbumDescription), findsOneWidget);
|
||||
final albumTrack = tester
|
||||
.widget<TrackListTile>(find.byType(TrackListTile).first)
|
||||
.track;
|
||||
@@ -193,5 +203,16 @@ void main() {
|
||||
expect(find.byType(ExplicitBadge), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(page);
|
||||
await tester.pumpAndSettle();
|
||||
expect(albumRequests, 1);
|
||||
expect(
|
||||
tester
|
||||
.widget<AlbumDescription>(find.byType(AlbumDescription))
|
||||
.description,
|
||||
'<p>A new direction.</p>',
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user