@@ -10,6 +10,7 @@ import 'media_backend.dart';
|
||||
import 'media_kind.dart';
|
||||
import 'media_role.dart';
|
||||
import 'media_version.dart';
|
||||
import 'season_title.dart';
|
||||
|
||||
part 'media_item.freezed.dart';
|
||||
part 'media_item.g.dart';
|
||||
@@ -401,8 +402,14 @@ sealed class MediaItem with _$MediaItem {
|
||||
|
||||
/// Subtitle line shown below [displayTitle] for episodes/seasons.
|
||||
String? get displaySubtitle {
|
||||
if (kind == MediaKind.episode || kind == MediaKind.season) {
|
||||
if (grandparentTitle != null || (kind == MediaKind.season && parentTitle != null)) {
|
||||
if (kind == MediaKind.season) {
|
||||
if (grandparentTitle != null || parentTitle != null) {
|
||||
// Re-localize a server's generic English "Season N" (see #1271).
|
||||
final label = localizedSeasonLabel(title: title, index: index);
|
||||
return label.isNotEmpty ? label : title;
|
||||
}
|
||||
} else if (kind == MediaKind.episode) {
|
||||
if (grandparentTitle != null) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'media_item.dart';
|
||||
import 'media_kind.dart';
|
||||
import 'season_title.dart';
|
||||
|
||||
/// Convenience type-check getters and spoiler helpers on [MediaItem]. These
|
||||
/// give consumers a Plex-style fluent API (e.g. `item.isShow`) while keeping
|
||||
@@ -27,4 +28,9 @@ extension MediaItemTypes on MediaItem {
|
||||
|
||||
/// Non-spoiler art path for episodes (show/season background).
|
||||
String? get spoilerSafeArt => grandparentArtPath ?? artPath;
|
||||
|
||||
/// Localized season label for this item. Re-localizes a server's generic
|
||||
/// English "Season N" to the app locale (see [localizedSeasonLabel]); falls
|
||||
/// back to [displayTitle] when there is no usable title or index.
|
||||
String get localizedSeasonTitle => localizedSeasonLabel(title: title, index: index, fallback: displayTitle);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Matches a media server's generic English season title, e.g. "Season 1".
|
||||
///
|
||||
/// Plex and Jellyfin return this verbatim when they have no localized name for a
|
||||
/// season (or when the request language resolves to English), which leaks
|
||||
/// untranslated "Season N" labels into otherwise-localized UI (see #1271).
|
||||
final RegExp _genericSeasonTitle = RegExp(r'^season\s+(\d+)$', caseSensitive: false);
|
||||
|
||||
/// Localized season label.
|
||||
///
|
||||
/// Replaces a generic English "Season N" (or an empty title) with the app-locale
|
||||
/// `t.common.seasonNumber`, while preserving custom names ("Specials", …) and
|
||||
/// titles the server already localized ("Saison 1").
|
||||
///
|
||||
/// Prefers [index] for the number; falls back to the digits parsed from a
|
||||
/// generic title, then to [fallback] when there is nothing usable to show.
|
||||
String localizedSeasonLabel({String? title, int? index, String? fallback}) {
|
||||
final raw = title?.trim() ?? '';
|
||||
final match = _genericSeasonTitle.firstMatch(raw);
|
||||
final number = index ?? (match != null ? int.tryParse(match.group(1)!) : null);
|
||||
if ((match != null || raw.isEmpty) && number != null) {
|
||||
return t.common.seasonNumber(number: number);
|
||||
}
|
||||
return raw.isNotEmpty ? raw : (fallback ?? '');
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import '../media/media_hub.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/plex_season_display.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/season_title.dart';
|
||||
import '../media/episode_collection.dart';
|
||||
import '../media/media_item_types.dart';
|
||||
import '../media/media_kind.dart';
|
||||
@@ -1669,7 +1670,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
id: seasonId,
|
||||
backend: _metadata.backend,
|
||||
kind: MediaKind.season,
|
||||
title: firstEp.parentTitle ?? t.common.seasonNumber(number: entry.key),
|
||||
title: localizedSeasonLabel(title: firstEp.parentTitle, index: entry.key),
|
||||
index: entry.key,
|
||||
leafCount: entry.value.length,
|
||||
thumbPath: firstEp.parentThumbPath,
|
||||
@@ -2335,7 +2336,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
onSecondaryTapDown: (details) => tapPosition = details.globalPosition,
|
||||
onSecondaryTap: () => _showSeasonTabContextMenu(index, position: tapPosition),
|
||||
child: FocusableTabChip(
|
||||
label: season.title!,
|
||||
label: season.localizedSeasonTitle,
|
||||
isSelected: index == _selectedSeasonIndex,
|
||||
topImage: topImage,
|
||||
focusNode: _seasonTabFocusNodes.length > index ? _seasonTabFocusNodes[index] : null,
|
||||
@@ -3739,7 +3740,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
hubs.add(
|
||||
MediaHub(
|
||||
id: '$_tvDetailSeasonHubIdPrefix$i',
|
||||
title: season.title?.isNotEmpty == true ? season.title! : (season.displaySubtitle ?? season.displayTitle),
|
||||
title: season.localizedSeasonTitle,
|
||||
type: 'episode',
|
||||
items: episodes,
|
||||
size: total,
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_item_types.dart';
|
||||
import '../media/season_title.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../mixins/context_menu_tap_mixin.dart';
|
||||
@@ -133,7 +134,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
final episodeInfo = item.parentIndex != null && item.index != null ? 'S${item.parentIndex} E${item.index}' : '';
|
||||
baseLabel = t.accessibility.mediaCardEpisode(title: item.displayTitle, episodeInfo: episodeInfo);
|
||||
case MediaKind.season:
|
||||
final seasonInfo = item.parentIndex != null ? 'Season ${item.parentIndex}' : '';
|
||||
final seasonInfo = localizedSeasonLabel(title: item.title, index: item.index);
|
||||
baseLabel = t.accessibility.mediaCardSeason(title: item.displayTitle, seasonInfo: seasonInfo);
|
||||
case MediaKind.movie:
|
||||
baseLabel = t.accessibility.mediaCardMovie(title: item.displayTitle);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_item_types.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/season_title.dart';
|
||||
|
||||
/// Pins the #1271 behavior: a server's generic English "Season N" title is
|
||||
/// re-localized to the current app locale, while custom / already-localized
|
||||
/// names pass through untouched.
|
||||
void main() {
|
||||
// Locales are lazy-loaded, so the non-base locale must be set asynchronously.
|
||||
setUpAll(() => LocaleSettings.setLocale(AppLocale.fr));
|
||||
tearDownAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
|
||||
MediaItem season({String? title, int? index}) => MediaItem(
|
||||
id: 'sn',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.season,
|
||||
title: title,
|
||||
index: index,
|
||||
parentTitle: 'Scrubs',
|
||||
serverId: 's1',
|
||||
);
|
||||
|
||||
group('localizedSeasonLabel (fr locale)', () {
|
||||
test('generic English "Season N" is re-localized', () {
|
||||
expect(localizedSeasonLabel(title: 'Season 3', index: 3), 'Saison 3');
|
||||
});
|
||||
|
||||
test('case-insensitive match', () {
|
||||
expect(localizedSeasonLabel(title: 'SEASON 1', index: 1), 'Saison 1');
|
||||
});
|
||||
|
||||
test('index is preferred over the number parsed from the title', () {
|
||||
expect(localizedSeasonLabel(title: 'Season 99', index: 5), 'Saison 5');
|
||||
});
|
||||
|
||||
test('falls back to digits parsed from the title when index is null', () {
|
||||
expect(localizedSeasonLabel(title: 'Season 3'), 'Saison 3');
|
||||
});
|
||||
|
||||
test('empty title with an index is localized', () {
|
||||
expect(localizedSeasonLabel(title: '', index: 5), 'Saison 5');
|
||||
expect(localizedSeasonLabel(title: null, index: 5), 'Saison 5');
|
||||
});
|
||||
|
||||
test('already-localized title is preserved', () {
|
||||
expect(localizedSeasonLabel(title: 'Saison 3', index: 3), 'Saison 3');
|
||||
});
|
||||
|
||||
test('custom season name is preserved', () {
|
||||
expect(localizedSeasonLabel(title: 'Specials', index: 0), 'Specials');
|
||||
expect(localizedSeasonLabel(title: 'The Lost Episodes', index: 1), 'The Lost Episodes');
|
||||
});
|
||||
|
||||
test('nothing usable falls back to the provided fallback', () {
|
||||
expect(localizedSeasonLabel(title: null, index: null, fallback: 'Unknown Season'), 'Unknown Season');
|
||||
expect(localizedSeasonLabel(title: ' ', index: null), '');
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaItem.localizedSeasonTitle', () {
|
||||
test('re-localizes a generic season title', () {
|
||||
expect(season(title: 'Season 2', index: 2).localizedSeasonTitle, 'Saison 2');
|
||||
});
|
||||
|
||||
test('falls back to displayTitle when there is no usable title or index', () {
|
||||
// displayTitle for a season prefers the show name (parentTitle).
|
||||
expect(season(title: null, index: null).localizedSeasonTitle, 'Scrubs');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user