diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 44eb6bea..e79b48f3 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -50,6 +50,11 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen totalSize > 0; + CardShape? get _contentShape { + final loaded = loadedItems.values; + return loaded.isNotEmpty && loaded.every((item) => item.kind.isMusic) ? CardShape.square : null; + } + @override void dispose() { disposePagination(); @@ -240,6 +245,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen ensureIndexLoaded(index, pageSize: _pageSize), collectionId: widget.collection.id, onListRefresh: loadItems, + shape: _contentShape, ), ], ); diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index cf831adb..37b7bba8 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -177,13 +177,14 @@ mixin FocusableDetailScreenMixin on State, GridFocu final viewMode = svc.read(SettingsService.viewMode); final libraryDensity = svc.read(SettingsService.libraryDensity); final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); + final useFullCardLayout = fullCardLayout && shape != CardShape.square; return MediaCardSliverLayout( viewMode: viewMode, itemCount: items.length, density: libraryDensity, padding: const EdgeInsets.all(8), - fullBleedImage: fullCardLayout, + fullBleedImage: useFullCardLayout, shape: shape, itemBuilder: (context, position) { final index = position.index; @@ -198,7 +199,8 @@ mixin FocusableDetailScreenMixin on State, GridFocu onRefresh: onRefresh, collectionId: collectionId, onListRefresh: onListRefresh, - fullBleedImage: fullCardLayout && position.isGrid, + fullBleedImage: useFullCardLayout && position.isGrid, + cardShapeOverride: shape, onNavigateUp: position.isFirstRow ? navigateToAppBar : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), @@ -220,6 +222,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu void Function(int index)? onSkeletonVisible, String? collectionId, VoidCallback? onListRefresh, + CardShape? shape, }) { return SettingsBuilder( prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], @@ -228,6 +231,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu final viewMode = svc.read(SettingsService.viewMode); final libraryDensity = svc.read(SettingsService.libraryDensity); final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); + final useFullCardLayout = fullCardLayout && shape != CardShape.square; Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) { final item = itemAt(index); @@ -244,7 +248,8 @@ mixin FocusableDetailScreenMixin on State, GridFocu onRefresh: onRefresh, collectionId: collectionId, onListRefresh: onListRefresh, - fullBleedImage: fullCardLayout && !disableScale, + fullBleedImage: useFullCardLayout && !disableScale, + cardShapeOverride: shape, onNavigateUp: inFirstRow ? navigateToAppBar : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), @@ -256,7 +261,8 @@ mixin FocusableDetailScreenMixin on State, GridFocu itemCount: totalItems, density: libraryDensity, padding: const EdgeInsets.all(8), - fullBleedImage: fullCardLayout, + fullBleedImage: useFullCardLayout, + shape: shape, itemBuilder: (context, position) => buildTile(position.index, inFirstRow: position.isFirstRow, disableScale: position.disableScale), ); diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 6756e133..8227c903 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -137,15 +137,21 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState widget.library.kind.isMusic; + Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { + final shape = _usesSquareCards ? CardShape.square : null; + final useFullCardLayout = fullCardLayout && shape != CardShape.square; return MediaCardSliverLayout( viewMode: viewMode, itemCount: totalSize, density: density, padding: _effectivePadding, - fullBleedImage: fullCardLayout, - listEpoch: (ViewMode.list, totalSize, density), - gridEpochBuilder: (geometry) => (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density), + fullBleedImage: useFullCardLayout, + shape: shape, + listEpoch: (ViewMode.list, totalSize, density, shape), + gridEpochBuilder: (geometry) => + (ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape), itemBuilder: (context, position) { final index = position.index; final item = loadedItems[index]; @@ -179,7 +185,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState widget.library.kind.isMusic; + Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { + final shape = _usesSquareCards ? CardShape.square : null; + final useFullCardLayout = fullCardLayout && shape != CardShape.square; return MediaCardSliverLayout( viewMode: viewMode, itemCount: totalSize, density: density, padding: _effectivePadding, - fullBleedImage: fullCardLayout, - listEpoch: (ViewMode.list, totalSize, density), - gridEpochBuilder: (geometry) => (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density), + fullBleedImage: useFullCardLayout, + shape: shape, + listEpoch: (ViewMode.list, totalSize, density, shape), + gridEpochBuilder: (geometry) => + (ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape), itemBuilder: (context, position) { final index = position.index; final playlist = loadedItems[index]; @@ -184,7 +191,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState with ContextMenuTap Widget _buildPosterImage(BuildContext context, MediaItem item) { final posterUrl = item.posterThumb(); + final isSquare = item.kind.isMusic; + final imageSize = isSquare ? const Size.square(60) : const Size(60, 90); return SizedBox( width: 60, height: 90, - child: Stack( - children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(6)), - child: OptimizedMediaImage.poster( - // Backend-neutral lookup so Jellyfin items render via their own - // image transcoder; null falls through to the placeholder below. - client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), - imagePath: posterUrl, - width: 60, - height: 90, - fit: BoxFit.cover, - placeholder: (context, url) => _buildPlaceholder(), - errorWidget: (context, url, error) => _buildPlaceholder(), - ), + child: Center( + child: SizedBox.fromSize( + size: imageSize, + child: Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(6)), + child: OptimizedMediaImage( + // Backend-neutral lookup so Jellyfin items render via their own + // image transcoder; null falls through to the placeholder below. + client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), + imagePath: posterUrl, + width: imageSize.width, + height: imageSize.height, + fit: BoxFit.cover, + placeholder: (context, url) => _buildPlaceholder(imageSize, item), + errorWidget: (context, url, error) => _buildPlaceholder(imageSize, item), + fallbackIcon: _fallbackIcon(item), + imageType: isSquare ? ImageType.square : ImageType.poster, + ), + ), + WatchedIndicator(item: item, size: WatchedIndicatorSize.compact), + ], ), - WatchedIndicator(item: item, size: WatchedIndicatorSize.compact), - ], + ), ), ); } - Widget _buildPlaceholder() { + Widget _buildPlaceholder(Size size, MediaItem item) { return Container( - width: 60, - height: 90, + width: size.width, + height: size.height, decoration: BoxDecoration(color: Colors.grey[850], borderRadius: const BorderRadius.all(Radius.circular(6))), - child: const AppIcon(Symbols.movie_rounded, fill: 1, color: Colors.grey, size: 24), + child: AppIcon(_fallbackIcon(item), fill: 1, color: Colors.grey, size: 24), ); } + IconData _fallbackIcon(MediaItem item) => switch (item.kind) { + MediaKind.artist => Symbols.artist_rounded, + MediaKind.album => Symbols.album_rounded, + MediaKind.track => Symbols.music_note_rounded, + MediaKind.show || MediaKind.season || MediaKind.episode => Symbols.tv_rounded, + _ => Symbols.movie_rounded, + }; + String _buildSubtitle(MediaItem item) { final kind = item.kind; diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index ab8c39cc..a58fff67 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -36,6 +36,10 @@ class FocusableMediaCard extends StatefulWidget { /// Render grid cards as image-only full-bleed cards. final bool fullBleedImage; + /// Overrides the silhouette inferred from [item]. Used for container types + /// such as music collections and playlists. + final CardShape? cardShapeOverride; + /// Show server name in list view (multi-server) final bool showServerName; @@ -87,6 +91,7 @@ class FocusableMediaCard extends StatefulWidget { this.isOffline = false, this.mixedHubContext = false, this.fullBleedImage = false, + this.cardShapeOverride, this.showServerName = false, this.disableScale = false, this.focusNode, @@ -142,6 +147,7 @@ class _FocusableMediaCardState extends State { isOffline: widget.isOffline, mixedHubContext: widget.mixedHubContext, fullBleedImage: widget.fullBleedImage, + cardShapeOverride: widget.cardShapeOverride, showServerName: widget.showServerName, ), ); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index c57a7eeb..26866fe0 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -140,6 +140,11 @@ class MediaCard extends StatefulWidget { final EpisodePosterMode? episodePosterModeOverride; final bool fullBleedImage; + /// Overrides the silhouette inferred from the item itself. Collection and + /// playlist records do not encode the media-library shape, so their owning + /// surface supplies this for music libraries. + final CardShape? cardShapeOverride; + const MediaCard({ super.key, required this.item, @@ -160,6 +165,7 @@ class MediaCard extends StatefulWidget { this.showServerName = false, this.episodePosterModeOverride, this.fullBleedImage = false, + this.cardShapeOverride, }) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching; @override @@ -309,6 +315,7 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin ImageType.square, + CardShape.wide => ImageType.thumb, + CardShape.poster => ImageType.poster, + null => MediaImageHelper.cardImageType(item, episodePosterMode, mixedHubContext: mixedHubContext), + }; Widget image; diff --git a/test/screens/collection_detail_screen_test.dart b/test/screens/collection_detail_screen_test.dart new file mode 100644 index 00000000..a4bd832f --- /dev/null +++ b/test/screens/collection_detail_screen_test.dart @@ -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(find.byType(MediaCardSliverLayout)); + expect(layout.shape, CardShape.square); + expect(layout.fullBleedImage, isFalse); + expect(tester.widget(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 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.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.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: downloadProvider), + ], + child: MaterialApp(theme: monoTheme(dark: true), home: child), + ), + ); + } +} + +class _CollectionClient implements MediaServerClient { + final List 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> 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); +} diff --git a/test/screens/libraries/library_collections_tab_test.dart b/test/screens/libraries/library_collections_tab_test.dart new file mode 100644 index 00000000..f3769a25 --- /dev/null +++ b/test/screens/libraries/library_collections_tab_test.dart @@ -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.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(find.byType(MediaCardSliverLayout)); + expect(layout.shape, CardShape.square); + expect(layout.fullBleedImage, isFalse); + expect(tester.widget(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 dispose() async { + provider.dispose(); + manager.dispose(); + await database.close(); + } +} diff --git a/test/screens/libraries/library_playlists_tab_test.dart b/test/screens/libraries/library_playlists_tab_test.dart index 46c51d2c..70f12788 100644 --- a/test/screens/libraries/library_playlists_tab_test.dart +++ b/test/screens/libraries/library_playlists_tab_test.dart @@ -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(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(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(find.byType(MediaCardSliverLayout)); + expect(layout.shape, CardShape.square); + expect(layout.fullBleedImage, isFalse); + expect( + tester + .widgetList(find.byType(FocusableMediaCard)) + .every((card) => card.cardShapeOverride == CardShape.square), + isTrue, + ); + }); } FocusableMediaCard _cardFor(List cards, int index) { @@ -137,6 +176,7 @@ FocusableMediaCard _cardFor(List cards, int index) { Future _pumpTab( WidgetTester tester, { required _PlaylistHarness harness, + required MediaLibrary library, required VoidCallback onBack, required VoidCallback onSidebar, }) async { @@ -169,7 +209,7 @@ Future _pumpTab( body: ValueListenableBuilder( valueListenable: harness.rebuild, builder: (context, _, _) => - LibraryPlaylistsTab(library: _library, suppressAutoFocus: true, onBack: onBack), + LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack), ), ), ), @@ -184,6 +224,7 @@ Future _pumpTab( class _PlaylistHarness { static const totalPlaylists = 400; + final String playlistType; final requestStarts = []; 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, }; diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 94fc1100..a143eebc 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -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(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(find.byType(MediaCardSliverLayout)); + expect(layout.shape, CardShape.square); + expect(layout.fullBleedImage, isFalse); + expect(tester.widget(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 _mediaItems(int count) { return List.generate( count, diff --git a/test/widgets/media_card_square_test.dart b/test/widgets/media_card_square_test.dart index 15509f47..32b8d951 100644 --- a/test/widgets/media_card_square_test.dart +++ b/test/widgets/media_card_square_test.dart @@ -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(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(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';