fix(music): keep collection and playlist artwork square

This commit is contained in:
edde746
2026-07-16 12:37:02 +02:00
parent d253450e9d
commit 517c3f57ce
13 changed files with 586 additions and 40 deletions
@@ -0,0 +1,158 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/collection_detail_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/media_card_sliver_layout.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/paged_fakes.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('music collection contents use square grid geometry and cards', (tester) async {
final album = testMediaItem(
id: 'album_1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album 1',
serverId: 'server_1',
serverName: 'Server',
);
final harness = await _createHarness([album]);
await tester.pumpWidget(
harness.wrap(SizedBox(width: 1280, height: 720, child: CollectionDetailScreen(collection: _collection))),
);
await tester.pumpAndSettle();
final layout = tester.widget<MediaCardSliverLayout>(find.byType(MediaCardSliverLayout));
expect(layout.shape, CardShape.square);
expect(layout.fullBleedImage, isFalse);
expect(tester.widget<FocusableMediaCard>(find.byType(FocusableMediaCard)).cardShapeOverride, CardShape.square);
});
}
final _collection = MediaItem(
id: 'collection_1',
backend: MediaBackend.plex,
kind: MediaKind.collection,
title: 'Music Collection',
libraryId: 'music',
serverId: 'server_1',
serverName: 'Server',
);
Future<_CollectionHarness> _createHarness(List<MediaItem> items) async {
await SettingsService.getInstance();
final database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
JellyfinApiCache.initialize(database);
final downloadManager = DownloadManagerService(
database: database,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
);
downloadManager.recoveryFuture = Future<void>.value();
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: database);
await downloadProvider.ensureInitialized();
final client = _CollectionClient(items);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(() async {
downloadProvider.dispose();
downloadManager.dispose();
multiServerProvider.dispose();
await database.close();
});
return _CollectionHarness(multiServerProvider: multiServerProvider, downloadProvider: downloadProvider);
}
class _CollectionHarness {
final MultiServerProvider multiServerProvider;
final DownloadProvider downloadProvider;
const _CollectionHarness({required this.multiServerProvider, required this.downloadProvider});
Widget wrap(Widget child) {
return TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
],
child: MaterialApp(theme: monoTheme(dark: true), home: child),
),
);
}
}
class _CollectionClient implements MediaServerClient {
final List<MediaItem> items;
const _CollectionClient(this.items);
@override
ServerId get serverId => ServerId('server_1');
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => ServerCapabilities.plex;
@override
Future<LibraryPage<MediaItem>> fetchCollectionPage(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? libraryId,
String? libraryTitle,
}) async {
return fakeLibraryPage(items, start: start, size: size);
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -0,0 +1,143 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/navigation/main_screen_scope.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/tabs/library_collections_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/card_inflation_budget.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/media_card_sliver_layout.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
final _serverId = ServerId('collection-server');
final _musicLibrary = MediaLibrary(
id: 'music',
backend: MediaBackend.plex,
title: 'Music',
kind: MediaKind.artist,
serverId: _serverId,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
CardInflationBudget.reset();
await SettingsService.getInstance();
});
testWidgets('music library collections use square grid geometry and square cards', (tester) async {
final harness = _CollectionHarness();
addTearDown(harness.dispose);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(800, 600);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>.value(
value: harness.provider,
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: 0,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, _) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)),
),
],
body: LibraryCollectionsTab(library: _musicLibrary, suppressAutoFocus: true, onBack: () {}),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
final layout = tester.widget<MediaCardSliverLayout>(find.byType(MediaCardSliverLayout));
expect(layout.shape, CardShape.square);
expect(layout.fullBleedImage, isFalse);
expect(tester.widget<FocusableMediaCard>(find.byType(FocusableMediaCard)).cardShapeOverride, CardShape.square);
});
}
class _CollectionHarness {
late final AppDatabase database;
late final MultiServerManager manager;
late final MultiServerProvider provider;
_CollectionHarness() {
database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: _serverId,
httpClient: MockClient((request) async {
if (request.url.path != '/library/sections/music/collections') {
return http.Response('not found', 404);
}
return http.Response(
jsonEncode({
'MediaContainer': {
'size': 1,
'totalSize': 1,
'Metadata': [
{'ratingKey': 'collection-1', 'type': 'collection', 'title': 'Music Collection', 'childCount': 4},
],
},
}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
manager = MultiServerManager()..debugRegisterClientForTesting(client);
provider = MultiServerProvider(manager, DataAggregationService(manager));
}
Future<void> dispose() async {
provider.dispose();
manager.dispose();
await database.close();
}
}
@@ -10,6 +10,7 @@ import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_playlist.dart';
@@ -25,6 +26,7 @@ import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/card_inflation_budget.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/media_card_sliver_layout.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
@@ -37,6 +39,13 @@ final _library = MediaLibrary(
kind: MediaKind.movie,
serverId: _serverId,
);
final _musicLibrary = MediaLibrary(
id: 'music',
backend: MediaBackend.plex,
title: 'Music',
kind: MediaKind.artist,
serverId: _serverId,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -55,7 +64,13 @@ void main() {
var backCalls = 0;
var sidebarCalls = 0;
await _pumpTab(tester, harness: harness, onBack: () => backCalls++, onSidebar: () => sidebarCalls++);
await _pumpTab(
tester,
harness: harness,
library: _library,
onBack: () => backCalls++,
onSidebar: () => sidebarCalls++,
);
expect(find.byType(SliverGrid), findsOneWidget);
final cards = tester.widgetList<FocusableMediaCard>(find.byType(FocusableMediaCard)).toList();
@@ -108,7 +123,13 @@ void main() {
var backCalls = 0;
var sidebarCalls = 0;
await _pumpTab(tester, harness: harness, onBack: () => backCalls++, onSidebar: () => sidebarCalls++);
await _pumpTab(
tester,
harness: harness,
library: _library,
onBack: () => backCalls++,
onSidebar: () => sidebarCalls++,
);
expect(find.byType(SliverList), findsOneWidget);
final cards = tester.widgetList<FocusableMediaCard>(find.byType(FocusableMediaCard)).toList();
@@ -128,6 +149,24 @@ void main() {
expect(backCalls, 1);
expect(sidebarCalls, 1);
});
testWidgets('music library playlists use square grid geometry and square cards', (tester) async {
final harness = _PlaylistHarness(playlistType: 'audio');
addTearDown(harness.dispose);
addTearDown(harness.rebuild.dispose);
await _pumpTab(tester, harness: harness, library: _musicLibrary, onBack: () {}, onSidebar: () {});
final layout = tester.widget<MediaCardSliverLayout>(find.byType(MediaCardSliverLayout));
expect(layout.shape, CardShape.square);
expect(layout.fullBleedImage, isFalse);
expect(
tester
.widgetList<FocusableMediaCard>(find.byType(FocusableMediaCard))
.every((card) => card.cardShapeOverride == CardShape.square),
isTrue,
);
});
}
FocusableMediaCard _cardFor(List<FocusableMediaCard> cards, int index) {
@@ -137,6 +176,7 @@ FocusableMediaCard _cardFor(List<FocusableMediaCard> cards, int index) {
Future<void> _pumpTab(
WidgetTester tester, {
required _PlaylistHarness harness,
required MediaLibrary library,
required VoidCallback onBack,
required VoidCallback onSidebar,
}) async {
@@ -169,7 +209,7 @@ Future<void> _pumpTab(
body: ValueListenableBuilder<int>(
valueListenable: harness.rebuild,
builder: (context, _, _) =>
LibraryPlaylistsTab(library: _library, suppressAutoFocus: true, onBack: onBack),
LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack),
),
),
),
@@ -184,6 +224,7 @@ Future<void> _pumpTab(
class _PlaylistHarness {
static const totalPlaylists = 400;
final String playlistType;
final requestStarts = <int>[];
final rebuild = ValueNotifier(0);
late final PlexClient client;
@@ -191,7 +232,7 @@ class _PlaylistHarness {
late final MultiServerManager manager;
late final MultiServerProvider provider;
_PlaylistHarness() {
_PlaylistHarness({this.playlistType = 'video'}) {
database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
client = PlexClient.forTesting(
@@ -214,7 +255,7 @@ class _PlaylistHarness {
return {
'ratingKey': 'playlist-$index',
'type': 'playlist',
'playlistType': 'video',
'playlistType': playlistType,
'title': 'Playlist $index',
'smart': false,
};
@@ -30,7 +30,11 @@ import 'package:plezy/utils/media_navigation_helper.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/media_card_sliver_layout.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/utils/media_image_helper.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
@@ -88,6 +92,59 @@ void main() {
expect(find.text(t.common.retry), findsNothing);
});
testWidgets('editable audio playlist rows render square track artwork', (tester) async {
final harness = await _createHarness([
testMediaItem(
id: 'track_1',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Track 1',
serverId: 'server_1',
serverName: 'Server',
),
]);
await tester.pumpWidget(
harness.wrap(const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _audioPlaylist))),
);
await tester.pumpAndSettle();
final card = find.byType(PlaylistItemCard);
final artwork = find.descendant(of: card, matching: find.byType(ClipRRect));
expect(tester.getSize(artwork), const Size.square(60));
expect(
tester
.widget<OptimizedMediaImage>(find.descendant(of: card, matching: find.byType(OptimizedMediaImage)))
.imageType,
ImageType.square,
);
});
testWidgets('read-only audio playlists use square grid geometry and cards', (tester) async {
final harness = await _createHarness([
testMediaItem(
id: 'track_1',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Track 1',
serverId: 'server_1',
serverName: 'Server',
),
]);
await tester.pumpWidget(
harness.wrap(
const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _smartAudioPlaylist)),
),
);
await tester.pumpAndSettle();
final layout = tester.widget<MediaCardSliverLayout>(find.byType(MediaCardSliverLayout));
expect(layout.shape, CardShape.square);
expect(layout.fullBleedImage, isFalse);
expect(tester.widget<FocusableMediaCard>(find.byType(FocusableMediaCard)).cardShapeOverride, CardShape.square);
});
testWidgets('keeps partial playlist pages and retries from the failed offset', (tester) async {
final items = _mediaItems(playlistItemsPageSize * 2 + 5);
final harness = await _createHarness(items, failOnceAt: playlistItemsPageSize);
@@ -404,6 +461,25 @@ const _playlist = MediaPlaylist(
serverName: 'Server',
);
const _audioPlaylist = MediaPlaylist(
id: 'audio_playlist_1',
backend: MediaBackend.plex,
title: 'Audio Playlist',
playlistType: 'audio',
serverId: 'server_1',
serverName: 'Server',
);
const _smartAudioPlaylist = MediaPlaylist(
id: 'smart_audio_playlist_1',
backend: MediaBackend.plex,
title: 'Smart Audio Playlist',
playlistType: 'audio',
smart: true,
serverId: 'server_1',
serverName: 'Server',
);
List<MediaItem> _mediaItems(int count) {
return List.generate(
count,
+44
View File
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_playlist.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/layout_constants.dart';
@@ -139,6 +140,49 @@ void main() {
expect(tester.getSize(imageBox), Size(base, base));
});
testWidgets('music collection override renders square artwork and requests a square transcode', (tester) async {
await tester.pumpWidget(
_TestApp(
child: MediaCard(
item: _item(MediaKind.collection),
width: 200,
height: 194,
forceGridMode: true,
isOffline: true,
cardShapeOverride: CardShape.square,
),
),
);
final imageBox = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first;
expect(tester.getSize(imageBox), const Size(194, 194));
expect(tester.widget<OptimizedMediaImage>(find.byType(OptimizedMediaImage)).imageType, ImageType.square);
});
testWidgets('music playlist override uses square artwork in list mode', (tester) async {
const playlist = MediaPlaylist(
id: 'playlist_1',
backend: MediaBackend.plex,
title: 'Music playlist',
playlistType: 'audio',
);
await tester.pumpWidget(
const _TestApp(
child: SizedBox(
width: 420,
height: 160,
child: MediaCard(item: playlist, forceListMode: true, isOffline: true, cardShapeOverride: CardShape.square),
),
),
);
final base = MediaCardListLayout.basePosterWidth(LibraryDensity.defaultValue);
final imageBox = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first;
expect(tester.getSize(imageBox), Size(base, base));
expect(tester.widget<OptimizedMediaImage>(find.byType(OptimizedMediaImage)).imageType, ImageType.square);
});
testWidgets('track artwork failure falls back to album artwork', (tester) async {
const trackArtwork = 'https://media.example/track.jpg';
const albumArtwork = 'https://media.example/album.jpg';