fix(i18n): localize generic season names from server

close #1271
This commit is contained in:
edde746
2026-06-07 21:46:24 +02:00
parent 381e82d61a
commit bf7558f80d
6 changed files with 121 additions and 6 deletions
+9 -2
View File
@@ -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;
}
}
+6
View File
@@ -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);
}
+26
View File
@@ -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 ?? '');
}
+4 -3
View File
@@ -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,
+2 -1
View File
@@ -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);