A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:
A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.
English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.
Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.
Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.
Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.
All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.
scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.
One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.
close #1856
285 lines
9.6 KiB
Dart
285 lines
9.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../../media/ids.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../focus/focusable_action_bar.dart';
|
|
import '../../focus/focusable_wrapper.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../media/media_server_client.dart';
|
|
import '../../models/livetv_channel.dart';
|
|
import '../../mixins/mounted_set_state_mixin.dart';
|
|
import '../../models/livetv_program.dart';
|
|
import '../../providers/multi_server_provider.dart';
|
|
import '../../theme/mono_tokens.dart';
|
|
import '../../utils/formatters.dart';
|
|
import '../../widgets/app_icon.dart';
|
|
import '../../widgets/focused_scroll_scaffold.dart';
|
|
import '../../widgets/loading_indicator_box.dart';
|
|
import '../../widgets/overlay_sheet.dart';
|
|
import '../../widgets/settings_section.dart';
|
|
import 'live_tv_actions_mixin.dart';
|
|
import 'livetv_recording_actions.dart';
|
|
import 'livetv_styles.dart';
|
|
|
|
/// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view.
|
|
class LiveTvShowScheduleScreen extends StatefulWidget {
|
|
/// The show title to filter for (grandparentTitle for episodes, title for movies).
|
|
final String showTitle;
|
|
|
|
/// Server ID to scope the EPG query.
|
|
final String serverId;
|
|
|
|
/// Full channel list for tuning.
|
|
final List<LiveTvChannel> channels;
|
|
|
|
const LiveTvShowScheduleScreen({super.key, required this.showTitle, required this.serverId, required this.channels});
|
|
|
|
@override
|
|
State<LiveTvShowScheduleScreen> createState() => _LiveTvShowScheduleScreenState();
|
|
}
|
|
|
|
class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
|
|
with LiveTvActionsMixin<LiveTvShowScheduleScreen>, MountedSetStateMixin {
|
|
List<LiveTvProgram> _programs = [];
|
|
bool _isLoading = true;
|
|
|
|
@override
|
|
List<LiveTvChannel> get liveTvChannels => widget.channels;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadSchedule();
|
|
}
|
|
|
|
Future<void> _loadSchedule() async {
|
|
final multiServer = context.read<MultiServerProvider>();
|
|
final genericClient = multiServer.getClientForServer(ServerId(widget.serverId));
|
|
if (genericClient == null) {
|
|
setStateIfMounted(() => _isLoading = false);
|
|
return;
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
// Fetch a generous window: 1h ago (to catch currently airing) + 48h ahead
|
|
final beginsAt = now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000;
|
|
final endsAt = now.add(const Duration(hours: 48)).millisecondsSinceEpoch ~/ 1000;
|
|
|
|
final fromDt = DateTime.fromMillisecondsSinceEpoch(beginsAt * 1000, isUtc: true);
|
|
final toDt = DateTime.fromMillisecondsSinceEpoch(endsAt * 1000, isUtc: true);
|
|
final programs = await genericClient.liveTv.fetchSchedule(from: fromDt, to: toDt);
|
|
|
|
// Filter for this show
|
|
final filtered = programs.where((p) {
|
|
if (p.grandparentTitle == widget.showTitle) return true;
|
|
if (p.grandparentTitle == null && p.title == widget.showTitle) return true;
|
|
return false;
|
|
}).toList();
|
|
|
|
// Sort by start time
|
|
filtered.sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_programs = filtered;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Resolve the owning client from the [MultiServerProvider]. The show
|
|
/// schedule screen is opened with a single [serverId], so no per-program
|
|
/// lookup is needed.
|
|
bool get _canRecord {
|
|
final client = context.read<MultiServerProvider>().getClientForServer(ServerId(widget.serverId));
|
|
return client?.liveTvDvr != null;
|
|
}
|
|
|
|
Future<void> _onRecordShow(BuildContext hostContext) async {
|
|
final client = context.read<MultiServerProvider>().getClientForServer(ServerId(widget.serverId));
|
|
if (client == null) return;
|
|
// Use the first program with a guid as the seed for `getSubscriptionTemplate`.
|
|
// The template returned by Plex includes both episode-level and series-level
|
|
// entries, so the user can still pick "Record Series" inside the options sheet.
|
|
LiveTvProgram? seed;
|
|
for (final p in _programs) {
|
|
if (p.guid != null && p.guid!.isNotEmpty) {
|
|
seed = p;
|
|
break;
|
|
}
|
|
}
|
|
if (seed == null) return;
|
|
await recordProgram(hostContext, client, seed);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final showRecord = _canRecord && _programs.any((p) => p.guid != null && p.guid!.isNotEmpty);
|
|
return OverlaySheetHost(
|
|
// Close an open sheet on system back instead of popping the screen.
|
|
canPop: true,
|
|
child: Builder(
|
|
builder: (hostContext) => FocusedScrollScaffold(
|
|
title: Text(widget.showTitle),
|
|
focusableAppBarActions: true,
|
|
actions: showRecord
|
|
? [
|
|
FocusableActionBar(
|
|
actions: [
|
|
FocusableAction(
|
|
icon: Symbols.fiber_manual_record_rounded,
|
|
tooltip: t.liveTv.recordShow,
|
|
onPressed: () => _onRecordShow(hostContext),
|
|
),
|
|
],
|
|
),
|
|
]
|
|
: null,
|
|
slivers: [
|
|
if (_isLoading)
|
|
LoadingIndicatorBox.sliver
|
|
else if (_programs.isEmpty)
|
|
SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms)))
|
|
else
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
for (var index = 0; index < _programs.length; index++) _buildScheduleItem(index, hostContext),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildScheduleItem(int index, BuildContext hostContext) {
|
|
final program = _programs[index];
|
|
final channel = findChannelForProgram(program);
|
|
void onTap() {
|
|
if (program.isCurrentlyAiring && channel != null) {
|
|
tuneChannel(channel);
|
|
} else {
|
|
showProgramDetails(
|
|
sheetContext: hostContext,
|
|
program: program,
|
|
channel: channel,
|
|
posterThumb: program.thumb,
|
|
posterServerId: widget.serverId,
|
|
);
|
|
}
|
|
}
|
|
|
|
return FocusableWrapper(
|
|
autofocus: index == 0,
|
|
autoScroll: true,
|
|
useComfortableZone: true,
|
|
useBackgroundFocus: true,
|
|
disableScale: true,
|
|
onSelect: onTap,
|
|
onBack: () => Navigator.pop(hostContext),
|
|
child: _ScheduleListTile(program: program, channel: channel, onTap: onTap),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScheduleListTile extends StatelessWidget {
|
|
final LiveTvProgram program;
|
|
final LiveTvChannel? channel;
|
|
final VoidCallback onTap;
|
|
|
|
const _ScheduleListTile({required this.program, required this.channel, required this.onTap});
|
|
|
|
String _formatTimeInfo({required bool is24Hour}) {
|
|
final now = DateTime.now();
|
|
final start = program.startTime;
|
|
final end = program.endTime;
|
|
if (start == null) return '';
|
|
|
|
if (program.isCurrentlyAiring && end != null) {
|
|
final minutesLeft = end.difference(now).inMinutes;
|
|
return t.discover.minutesLeft(minutes: minutesLeft);
|
|
}
|
|
|
|
final minutesUntil = start.difference(now).inMinutes;
|
|
if (minutesUntil <= 0) {
|
|
// Just started
|
|
return _formatAbsoluteTime(start, now, is24Hour: is24Hour);
|
|
} else if (minutesUntil < 90) {
|
|
return t.liveTv.startingInMinutes(minutes: minutesUntil);
|
|
} else {
|
|
return _formatAbsoluteTime(start, now, is24Hour: is24Hour);
|
|
}
|
|
}
|
|
|
|
String _formatAbsoluteTime(DateTime start, DateTime now, {required bool is24Hour}) {
|
|
final time = formatClockTime(start, is24Hour: is24Hour);
|
|
return t.liveTv.dayAtTime(
|
|
day: formatRelativeDayLabel(start, now: now),
|
|
time: time,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final isLive = program.isCurrentlyAiring;
|
|
|
|
// Title line: S#·E# — Episode Title, or just Title for non-episodes
|
|
final titleText = (program.parentIndex != null && program.index != null)
|
|
? 'S${program.parentIndex} · E${program.index} — ${program.title}'
|
|
: program.title;
|
|
|
|
final timeInfo = _formatTimeInfo(is24Hour: MediaQuery.alwaysUse24HourFormatOf(context));
|
|
final subtitle = [
|
|
timeInfo,
|
|
if (program.summary != null && program.summary!.isNotEmpty) program.summary!,
|
|
].join(' — ');
|
|
|
|
return InkWell(
|
|
canRequestFocus: false,
|
|
onTap: onTap,
|
|
child: Container(
|
|
color: isLive ? airingFill(context) : null,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
titleText,
|
|
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: .w500),
|
|
maxLines: 1,
|
|
overflow: .ellipsis,
|
|
),
|
|
),
|
|
if (isLive) ...[
|
|
const SizedBox(width: 8),
|
|
AppIcon(Symbols.play_circle_rounded, size: 20, color: theme.colorScheme.primary),
|
|
],
|
|
],
|
|
),
|
|
if (subtitle.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitle,
|
|
style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted),
|
|
maxLines: 2,
|
|
overflow: .ellipsis,
|
|
),
|
|
],
|
|
if (channel != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(channel!.displayName, style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted)),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|