fix(music): preserve Jellyfin collection posters

This commit is contained in:
edde746
2026-07-16 14:36:48 +02:00
parent ff602f1456
commit b7cd4e5e9c
5 changed files with 145 additions and 42 deletions
@@ -137,7 +137,15 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
return base.copyWith(top: base.top + _focusDecorationPadding);
}
bool get _usesSquareCards => widget.library.kind.isMusic;
bool get _usesSquareCards {
final loaded = loadedItems.values;
return loaded.isNotEmpty && loaded.every(_isMusicCollection);
}
// Plex collection rows are library-scoped, so their container kind is a
// safe fallback. Jellyfin BoxSets are server-wide and must opt in per item.
bool _isMusicCollection(MediaItem item) =>
item.kind.isMusic || (item is PlexMediaItem && widget.library.kind.isMusic);
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
final shape = _usesSquareCards ? CardShape.square : null;
@@ -22,6 +22,7 @@ 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/utils/platform_detector.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/media_card_sliver_layout.dart';
import 'package:provider/provider.dart';
@@ -37,8 +38,11 @@ void main() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(false);
});
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
testWidgets('music collection contents use square grid geometry and cards', (tester) async {
final album = testMediaItem(
id: 'album_1',
@@ -49,6 +53,8 @@ void main() {
serverName: 'Server',
);
final harness = await _createHarness([album]);
TvDetectionService.debugSetAppleTVOverride(true);
await SettingsService.instance.write(SettingsService.tvFullCardLayout, true);
await tester.pumpWidget(
harness.wrap(SizedBox(width: 1280, height: 720, child: CollectionDetailScreen(collection: _collection))),
@@ -12,24 +12,30 @@ 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_server_client.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/jellyfin_api_cache.dart';
import 'package:plezy/services/jellyfin_client.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/utils/platform_detector.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/backend_client_fixtures.dart';
import '../../test_helpers/prefs.dart';
final _serverId = ServerId('collection-server');
final _jellyfinServerId = ServerId('jellyfin-collection-server');
final _musicLibrary = MediaLibrary(
id: 'music',
backend: MediaBackend.plex,
@@ -37,6 +43,13 @@ final _musicLibrary = MediaLibrary(
kind: MediaKind.artist,
serverId: _serverId,
);
final _jellyfinMusicLibrary = MediaLibrary(
id: 'music-library',
backend: MediaBackend.jellyfin,
title: 'Music',
kind: MediaKind.artist,
serverId: _jellyfinServerId,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -45,63 +58,91 @@ void main() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
CardInflationBudget.reset();
TvDetectionService.debugSetAppleTVOverride(false);
await SettingsService.getInstance();
});
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
testWidgets('music library collections use square grid geometry and square cards', (tester) async {
final harness = _CollectionHarness();
final harness = _CollectionHarness.plex();
addTearDown(harness.dispose);
TvDetectionService.debugSetAppleTVOverride(true);
await SettingsService.instance.write(SettingsService.tvFullCardLayout, true);
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();
await _pumpTab(tester, harness: harness, library: _musicLibrary);
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('Jellyfin video collections keep poster geometry when opened from a music library', (tester) async {
final harness = _CollectionHarness.jellyfin();
addTearDown(harness.dispose);
TvDetectionService.debugSetAppleTVOverride(true);
await SettingsService.instance.write(SettingsService.tvFullCardLayout, true);
await _pumpTab(tester, harness: harness, library: _jellyfinMusicLibrary);
final layout = tester.widget<MediaCardSliverLayout>(find.byType(MediaCardSliverLayout));
expect(layout.shape, isNull);
expect(layout.fullBleedImage, isTrue);
expect(tester.widget<FocusableMediaCard>(find.byType(FocusableMediaCard)).cardShapeOverride, isNull);
});
}
Future<void> _pumpTab(WidgetTester tester, {required _CollectionHarness harness, required MediaLibrary library}) async {
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: library, suppressAutoFocus: true, onBack: () {}),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
}
class _CollectionHarness {
late final AppDatabase database;
final AppDatabase database;
late final MultiServerManager manager;
late final MultiServerProvider provider;
_CollectionHarness() {
database = AppDatabase.forTesting(NativeDatabase.memory());
_CollectionHarness._({required this.database, required MediaServerClient client}) {
manager = MultiServerManager()..debugRegisterClientForTesting(client);
provider = MultiServerProvider(manager, DataAggregationService(manager));
}
factory _CollectionHarness.plex() {
final database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
final client = PlexClient.forTesting(
config: PlexConfig(
@@ -131,8 +172,48 @@ class _CollectionHarness {
);
}),
);
manager = MultiServerManager()..debugRegisterClientForTesting(client);
provider = MultiServerProvider(manager, DataAggregationService(manager));
return _CollectionHarness._(database: database, client: client);
}
factory _CollectionHarness.jellyfin() {
final database = AppDatabase.forTesting(NativeDatabase.memory());
JellyfinApiCache.initialize(database);
final client = JellyfinClient.forTesting(
connection: testJellyfinConnection(machineId: _jellyfinServerId),
httpClient: MockClient((request) async {
if (request.url.path == '/Users/user-1/Views') {
return http.Response(
jsonEncode({
'Items': [
{'Id': 'boxsets-root', 'Name': 'Collections', 'CollectionType': 'boxsets'},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == '/Items') {
return http.Response(
jsonEncode({
'TotalRecordCount': 1,
'Items': [
{
'Id': 'video-collection-1',
'Name': 'Movie Collection',
'Type': 'BoxSet',
'MediaType': 'Video',
'ParentId': 'boxsets-root',
},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('not found', 404);
}),
);
return _CollectionHarness._(database: database, client: client);
}
Future<void> dispose() async {
@@ -24,6 +24,7 @@ import 'package:plezy/services/plex_client.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/platform_detector.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';
@@ -54,9 +55,12 @@ void main() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
CardInflationBudget.reset();
TvDetectionService.debugSetAppleTVOverride(false);
await SettingsService.getInstance();
});
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
testWidgets('grid lazily builds playlist cards and preserves focus navigation', (tester) async {
final harness = _PlaylistHarness();
addTearDown(harness.dispose);
@@ -154,6 +158,8 @@ void main() {
final harness = _PlaylistHarness(playlistType: 'audio');
addTearDown(harness.dispose);
addTearDown(harness.rebuild.dispose);
TvDetectionService.debugSetAppleTVOverride(true);
await SettingsService.instance.write(SettingsService.tvFullCardLayout, true);
await _pumpTab(tester, harness: harness, library: _musicLibrary, onBack: () {}, onSidebar: () {});
@@ -131,6 +131,8 @@ void main() {
serverName: 'Server',
),
]);
TvDetectionService.debugSetAppleTVOverride(true);
await SettingsService.instance.write(SettingsService.tvFullCardLayout, true);
await tester.pumpWidget(
harness.wrap(