refactor: share focus chrome and simplify the TV picker and browse paths

Focus chrome was implemented twice, once in the focusable wrapper and once
in the focus builders; both now go through FocusChrome. TvColorPicker's
channel row was a copy of TvNumberSpinner and is now that widget in compact
density.

Also trims unused helpers and fields and simplifies the Jellyfin browse
paths.
This commit is contained in:
edde746
2026-07-26 06:09:49 +02:00
parent c68ffe9ed0
commit 4eaf4423a1
47 changed files with 526 additions and 1133 deletions
-4
View File
@@ -95,21 +95,18 @@ void main() {
final movie = _movie(artPath: '/art', backgroundSquarePath: '/square');
expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/art']);
expect(movie.heroArt(containerAspectRatio: 1.0), '/square');
});
test('near-square containers fall back to wide cover art when square art is missing', () {
final movie = _movie(artPath: '/art');
expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/art']);
expect(movie.heroArt(containerAspectRatio: 1.0), '/art');
});
test('wide containers prefer wide cover art before square art', () {
final movie = _movie(artPath: '/art', backgroundSquarePath: '/square');
expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art', '/square']);
expect(movie.heroArt(containerAspectRatio: 16 / 9), '/art');
});
test('episodes prefer show art before episode art for wide hero containers', () {
@@ -126,7 +123,6 @@ void main() {
);
expect(episode.heroArtCandidates(containerAspectRatio: 16 / 9), ['/show-art', '/episode-art', '/square']);
expect(episode.heroArt(containerAspectRatio: 16 / 9), '/show-art');
expect(episode.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/show-art', '/episode-art']);
});
-11
View File
@@ -126,16 +126,6 @@ void main() {
});
});
group('MediaPlaylist.isEditable', () {
test('smart playlists are read-only (Plex semantics)', () {
expect(_playlist(smart: true).isEditable, isFalse);
});
test('manual playlists are editable', () {
expect(_playlist(smart: false).isEditable, isTrue);
});
});
group('MediaPlaylist.globalKey', () {
test('uses "<serverId>:<id>" when serverId is set', () {
final pl = _playlist(id: 'pl-42', serverId: 'srv-9');
@@ -166,7 +156,6 @@ void main() {
expect(minimal.serverName, isNull);
expect(minimal.displayImagePath, isNull);
expect(minimal.displayTitle, 'Min');
expect(minimal.isEditable, isTrue);
// Without a serverId, globalKey reduces to the bare id.
expect(minimal.globalKey, 'pl');
});
-18
View File
@@ -70,23 +70,5 @@ void main() {
expect(profile.watchedIndicator, 2);
expect(profile.defaultSubtitleForced, 1);
});
test('defaults() matches parsing an empty map', () {
final parsed = PlexUserProfile.fromJson(const {});
final defaults = PlexUserProfile.defaults();
expect(defaults.autoSelectAudio, parsed.autoSelectAudio);
expect(defaults.defaultAudioAccessibility, parsed.defaultAudioAccessibility);
expect(defaults.defaultAudioLanguage, parsed.defaultAudioLanguage);
expect(defaults.defaultAudioLanguages, parsed.defaultAudioLanguages);
expect(defaults.defaultSubtitleLanguage, parsed.defaultSubtitleLanguage);
expect(defaults.defaultSubtitleLanguages, parsed.defaultSubtitleLanguages);
expect(defaults.autoSelectSubtitle, parsed.autoSelectSubtitle);
expect(defaults.defaultSubtitleAccessibility, parsed.defaultSubtitleAccessibility);
expect(defaults.defaultSubtitleForced, parsed.defaultSubtitleForced);
expect(defaults.watchedIndicator, parsed.watchedIndicator);
expect(defaults.mediaReviewsVisibility, parsed.mediaReviewsVisibility);
expect(defaults.mediaReviewsLanguages, parsed.mediaReviewsLanguages);
});
});
}
+10 -34
View File
@@ -49,34 +49,17 @@ Map<String, dynamic> driftedSwitchJson() => {
};
void main() {
group('UserSwitchResponse.fromJson', () {
test('parses a realistic drifted 201 body, preserving the token', () {
final response = UserSwitchResponse.fromJson(driftedSwitchJson());
expect(response.authToken, 'minted-user-token');
expect(response.uuid, 'e443d57860076fc3');
expect(response.protected, isTrue);
expect(response.homeAdmin, isTrue);
expect(response.profile.defaultAudioLanguages, ['en', 'sv']);
expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']);
group('parsePlexSwitchAuthToken', () {
test('takes the token out of a realistic drifted 201 body', () {
expect(parsePlexSwitchAuthToken(driftedSwitchJson()), 'minted-user-token');
});
test('parses a token-only body with defaults everywhere else', () {
final response = UserSwitchResponse.fromJson({'authToken': 'tok'});
expect(response.authToken, 'tok');
expect(response.id, 0);
expect(response.uuid, '');
expect(response.title, '');
expect(response.confirmed, isFalse);
expect(response.homeSize, 1);
expect(response.maxHomeSize, 1);
expect(response.profile.autoSelectAudio, isTrue);
expect(response.profile.defaultAudioLanguages, isNull);
test('takes the token out of a token-only body', () {
expect(parsePlexSwitchAuthToken({'authToken': 'tok'}), 'tok');
});
test('never loses the token to wrong-typed decorative fields', () {
final response = UserSwitchResponse.fromJson({
final token = parsePlexSwitchAuthToken({
'authToken': 'tok',
'id': {},
'uuid': 42,
@@ -92,20 +75,13 @@ void main() {
'twoFactorEnabled': {},
});
expect(response.authToken, 'tok');
expect(response.id, 0);
expect(response.uuid, '42');
expect(response.title, '7');
expect(response.confirmed, isFalse);
expect(response.homeSize, 1);
expect(response.profile.autoSelectAudio, isTrue);
expect(response.profile.defaultAudioLanguages, isNull);
expect(token, 'tok');
});
test('throws when authToken is missing, empty, or not a string', () {
expect(() => UserSwitchResponse.fromJson(const {}), throwsFormatException);
expect(() => UserSwitchResponse.fromJson({'authToken': ''}), throwsFormatException);
expect(() => UserSwitchResponse.fromJson({'authToken': 12345}), throwsFormatException);
expect(() => parsePlexSwitchAuthToken(const {}), throwsFormatException);
expect(() => parsePlexSwitchAuthToken({'authToken': ''}), throwsFormatException);
expect(() => parsePlexSwitchAuthToken({'authToken': 12345}), throwsFormatException);
});
});
}
+1 -12
View File
@@ -123,27 +123,16 @@ void main() {
expect(m.serverIds, isEmpty);
expect(m.onlineServerIds, isEmpty);
expect(m.offlineServerIds, isEmpty);
expect(m.plexServers, isEmpty);
expect(m.onlineClients, isEmpty);
});
test('getClient/getPlexServer return null for unknown ids', () {
test('getClient returns null for unknown ids', () {
final m = MultiServerManager();
addTearDown(m.dispose);
expect(m.getClient(ServerId('nope')), isNull);
expect(m.getPlexServer(ServerId('nope')), isNull);
expect(m.isServerOnline(ServerId('nope')), isFalse);
});
test('plexServers map is unmodifiable', () {
final m = MultiServerManager();
addTearDown(m.dispose);
// Map.unmodifiable rejects every mutating operation — clear() is the
// simplest no-arg one to exercise the wrapper.
expect(() => m.plexServers.clear(), throwsUnsupportedError);
});
});
// ============================================================
@@ -113,15 +113,15 @@ void main() {
final tracks = [_sourceSubtitle(0), _sourceSubtitle(2)];
expect(
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off()),
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 1),
const PlaybackSourceSubtitleChoice.source(0),
);
expect(
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0)),
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0), 1),
const PlaybackSourceSubtitleChoice.source(2),
);
expect(
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2)),
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2), 1),
const PlaybackSourceSubtitleChoice.off(),
);
expect(
+2 -4
View File
@@ -73,11 +73,9 @@ void main() {
addTearDown(client.close);
final auth = PlexAuthService.forTesting(http: client);
final response = await auth.switchToUser('uuid-1', 'account-token');
final token = await auth.switchToUser('uuid-1', 'account-token');
expect(response.authToken, 'minted-user-token');
expect(response.profile.defaultAudioLanguages, ['en', 'sv']);
expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']);
expect(token, 'minted-user-token');
});
test('fetchServers tolerates scalar drift in server and connection fields', () async {
+2 -2
View File
@@ -13,7 +13,7 @@ void main() {
expect(manager.setZoomScale(1.234), 1.23);
expect(manager.zoomScale, 1.23);
expect(manager.adjustZoom(VideoFilterManager.zoomStep), 1.24);
expect(manager.setZoomScale(manager.zoomScale + VideoFilterManager.zoomStep), 1.24);
expect(manager.zoomScale, 1.24);
});
@@ -26,7 +26,7 @@ void main() {
expect(manager.setZoomScale(1.00008), 1.0);
expect(manager.zoomScale, 1.0);
expect(manager.resetZoom(), 1.0);
expect(manager.setZoomScale(1.0), 1.0);
});
test('video zoom property is exact zero at normalized default', () async {
-4
View File
@@ -95,10 +95,6 @@ void main() {
expect(ContentTypeHelper.isVideoContent('artist'), isFalse);
});
test('isMusicLibrary returns false for null and non-matching types', () {
expect(ContentTypeHelper.isMusicLibrary(null), isFalse);
});
test('getLibraryIcon normalizes type and falls back to folder', () {
expect(ContentTypeHelper.getLibraryIcon('MOVIE'), Symbols.movie_rounded);
expect(ContentTypeHelper.getLibraryIcon('show'), Symbols.tv_rounded);
-13
View File
@@ -18,13 +18,6 @@ void main() {
expect(ScreenBreakpoints.isTablet(1200), isFalse);
});
test('isWideTablet: 900 ≤ w < 1200', () {
expect(ScreenBreakpoints.isWideTablet(899.9), isFalse);
expect(ScreenBreakpoints.isWideTablet(900), isTrue);
expect(ScreenBreakpoints.isWideTablet(1199.9), isTrue);
expect(ScreenBreakpoints.isWideTablet(1200), isFalse);
});
test('isDesktop: 1200 ≤ w < 1600', () {
expect(ScreenBreakpoints.isDesktop(1199.9), isFalse);
expect(ScreenBreakpoints.isDesktop(1200), isTrue);
@@ -32,12 +25,6 @@ void main() {
expect(ScreenBreakpoints.isDesktop(1600), isFalse);
});
test('isLargeDesktop: w ≥ 1600', () {
expect(ScreenBreakpoints.isLargeDesktop(1599.9), isFalse);
expect(ScreenBreakpoints.isLargeDesktop(1600), isTrue);
expect(ScreenBreakpoints.isLargeDesktop(10000), isTrue);
});
test('isDesktopOrLarger: w ≥ 1200', () {
expect(ScreenBreakpoints.isDesktopOrLarger(1199.9), isFalse);
expect(ScreenBreakpoints.isDesktopOrLarger(1200), isTrue);
-43
View File
@@ -61,47 +61,4 @@ void main() {
expect(result, equals(first));
});
});
group('PlexCacheParser.extractChapters', () {
test('returns null for null input', () {
expect(PlexCacheParser.extractChapters(null), isNull);
});
test('returns null when no metadata', () {
expect(
PlexCacheParser.extractChapters({
'MediaContainer': {'Metadata': []},
}),
isNull,
);
});
test('returns null when first metadata has no Chapter key', () {
expect(
PlexCacheParser.extractChapters({
'MediaContainer': {
'Metadata': [
<String, dynamic>{'ratingKey': '1'},
],
},
}),
isNull,
);
});
test('returns chapter list when present', () {
final chapters = [
{'tag': 'Chapter 1'},
{'tag': 'Chapter 2'},
];
final result = PlexCacheParser.extractChapters({
'MediaContainer': {
'Metadata': [
{'ratingKey': '1', 'Chapter': chapters},
],
},
});
expect(result, equals(chapters));
});
});
}