From f0debe2c3200af6d2117b7540702773f189a697b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:07:36 +0200 Subject: [PATCH] feat(ui): show a system-format clock on TV home and in the player The clock renders through the existing formatClockTime helper driven by MediaQuery.alwaysUse24HourFormatOf, so it follows the OS 12/24-hour setting instead of introducing an app preference. It re-arms a one-shot timer onto each wall-clock minute boundary rather than polling, and resyncs on resume because a suspended process runs no timers. The player header is shared by the mobile and desktop/TV controls, so one insertion point covers every form factor: the player is fullscreen everywhere, so it never has an OS clock to defer to. Home is the exception and only gets one on TV, where a leanback app hides the system clock; a phone status bar and a desktop menu bar already show the time. --- lib/screens/discover_screen.dart | 9 ++ lib/widgets/system_clock.dart | 79 ++++++++++++ .../widgets/video_controls_header.dart | 7 ++ test/screens/discover_screen_test.dart | 114 ++++++++++++++++++ test/widgets/system_clock_test.dart | 84 +++++++++++++ test/widgets/video_controls_header_test.dart | 6 +- 6 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/system_clock.dart create mode 100644 test/widgets/system_clock_test.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index b3904a91..7201092e 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -22,6 +22,7 @@ import '../utils/content_utils.dart'; import '../widgets/cycling_media_backdrop.dart'; import '../widgets/optimized_media_image.dart' show ClearLogoImage, blurArtwork; import '../widgets/toolbar_scrim.dart'; +import '../widgets/system_clock.dart'; import '../providers/discover_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/watch_state_store.dart'; @@ -748,6 +749,14 @@ class _DiscoverScreenState extends State style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold), ), const Spacer(), + // TV only: a fullscreen leanback app hides the system clock, while a + // phone status bar and a desktop menu bar already show one. + if (PlatformDetector.isTV()) ...[ + SystemClock( + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w500), + ), + const SizedBox(width: 12), + ], Consumer2( builder: (context, watchTogether, companionRemote, _) { final isDesktop = PlatformDetector.shouldActAsRemoteHost(context); diff --git a/lib/widgets/system_clock.dart b/lib/widgets/system_clock.dart new file mode 100644 index 00000000..7a486e92 --- /dev/null +++ b/lib/widgets/system_clock.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../utils/formatters.dart'; + +/// Live wall-clock label that follows the system 12/24-hour preference. +/// +/// The displayed value comes from [formatClockTime] with +/// `MediaQuery.alwaysUse24HourFormatOf`, so it matches the format every other +/// time-of-day label in the app uses and flips as soon as the OS setting does. +/// +/// Ticking is a one-shot timer re-armed onto each wall-clock minute boundary +/// rather than a one-second poll: one rebuild per minute, and the minute never +/// flips a fraction of a second late. A suspended process runs no timers, so +/// the clock also resynchronises on resume instead of waiting out the boundary +/// that elapsed while it was away. +class SystemClock extends StatefulWidget { + const SystemClock({super.key, this.style, this.now = DateTime.now}); + + /// Text style for the clock label. The two chrome surfaces that host it use + /// different foregrounds, so the caller owns colour and size. + final TextStyle? style; + + /// Wall-clock source. Overridden only by tests, which need the minute + /// rollover to be deterministic. + final DateTime Function() now; + + @override + State createState() => _SystemClockState(); +} + +class _SystemClockState extends State with WidgetsBindingObserver { + Timer? _tick; + late DateTime _now; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _now = widget.now(); + _scheduleTick(); + } + + @override + void dispose() { + _tick?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _tickNow(); + } + + void _scheduleTick() { + final now = _now; + final nextMinute = DateTime(now.year, now.month, now.day, now.hour, now.minute).add(const Duration(minutes: 1)); + _tick?.cancel(); + _tick = Timer(nextMinute.difference(now), _tickNow); + } + + void _tickNow() { + if (!mounted) return; + setState(() => _now = widget.now()); + _scheduleTick(); + } + + @override + Widget build(BuildContext context) { + return Text( + formatClockTime(_now, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context)), + style: widget.style, + maxLines: 1, + softWrap: false, + ); + } +} diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index 1c7060ba..483abeb3 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -7,6 +7,7 @@ import '../../../i18n/strings.g.dart'; import '../../../watch_together/widgets/watch_together_overlay.dart'; import '../../../watch_together/providers/watch_together_provider.dart'; import '../../app_bar_back_button.dart'; +import '../../system_clock.dart'; /// Header layout style for video controls enum VideoHeaderStyle { @@ -68,6 +69,12 @@ class VideoControlsHeader extends StatelessWidget { ); }, ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: SystemClock( + style: const TextStyle(color: Colors.white70, fontSize: 14, fontWeight: .w500), + ), + ), ?trailing, ], ); diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 8619f42c..be40dcfb 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:drift/native.dart'; +import 'package:intl/date_symbol_data_local.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; @@ -43,6 +44,7 @@ import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/watch_together/watch_together.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; +import 'package:plezy/widgets/system_clock.dart'; import 'package:plezy/widgets/tv_spotlight_background.dart'; import 'package:provider/provider.dart'; @@ -53,6 +55,8 @@ import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => initializeDateFormatting('en')); + setUp(() { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); @@ -627,6 +631,116 @@ void main() { // the binding's pending-timer check. await tester.pumpWidget(const SizedBox()); }); + + testWidgets('the home clock is TV-only chrome', (tester) async { + await _pumpDiscoverShell(tester, isTv: false); + expect(find.text(t.discover.title), findsOneWidget); + expect( + find.byType(SystemClock), + findsNothing, + reason: 'a phone status bar and a desktop menu bar already show the time', + ); + await tester.pumpWidget(const SizedBox()); + + await _pumpDiscoverShell(tester, isTv: true); + expect(find.byType(SystemClock), findsOneWidget, reason: 'a fullscreen leanback app hides the system clock'); + await tester.pumpWidget(const SizedBox()); + }); +} + +/// Mounts [DiscoverScreen] in the smallest graph both layout branches need, so +/// one test can compare the TV and non-TV chrome without rebuilding it twice. +Future _pumpDiscoverShell(WidgetTester tester, {required bool isTv}) async { + TvDetectionService.debugSetAppleTVOverride(isTv); + await SettingsService.getInstance(); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final client = _FakeMediaServerClient(hubs: const []); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = testMultiServerProvider(manager); + final hiddenLibrariesProvider = HiddenLibrariesProvider(); + final librariesProvider = LibrariesProvider(); + final watchTogetherProvider = WatchTogetherProvider(); + final companionRemoteProvider = CompanionRemoteProvider(); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final storage = await StorageService.getInstance(); + final connectionRegistry = _FakeConnectionRegistry(db); + final profileConnectionRegistry = _FakeProfileConnectionRegistry(db); + final plexHome = PlexHomeService( + connections: connectionRegistry, + profileConnections: profileConnectionRegistry, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfileProvider = ActiveProfileProvider( + registry: _FakeProfileRegistry(db), + plexHome: plexHome, + connections: connectionRegistry, + profileConnections: profileConnectionRegistry, + storage: storage, + ); + final discoverProvider = DiscoverProvider( + multiServerProvider, + hiddenLibrariesProvider, + librariesProvider, + profileId: null, + isProfileBinding: () => activeProfileProvider.isBinding, + ); + + addTearDown(() async { + discoverProvider.dispose(); + activeProfileProvider.dispose(); + companionRemoteProvider.dispose(); + watchTogetherProvider.dispose(); + librariesProvider.dispose(); + hiddenLibrariesProvider.dispose(); + multiServerProvider.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: hiddenLibrariesProvider), + ChangeNotifierProvider.value(value: librariesProvider), + ChangeNotifierProvider.value(value: watchTogetherProvider), + ChangeNotifierProvider.value(value: companionRemoteProvider), + ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: discoverProvider), + ], + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: MainScreenFocusScope( + focusSidebar: () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: SideNavigationRailState.expandedWidth, + reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth, + foregroundLeft: 0, + foregroundWidth: 1280, + viewportWidth: 1280, + child: const DiscoverScreen(), + ), + ), + ), + ), + ), + ); + + // Bounded pumps only: the hero runs periodic auto-scroll timers, so + // pumpAndSettle would never settle. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); } class _FakeMediaServerClient implements MediaServerClient { diff --git a/test/widgets/system_clock_test.dart b/test/widgets/system_clock_test.dart new file mode 100644 index 00000000..493a48de --- /dev/null +++ b/test/widgets/system_clock_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/widgets/system_clock.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + }); + + testWidgets('renders 24-hour time when the system asks for it', (tester) async { + await _pumpClock(tester, now: () => DateTime(2026, 8, 8, 18, 5), use24Hour: true); + + expect(find.text('18:05'), findsOneWidget); + }); + + testWidgets('renders 12-hour time when the system asks for it', (tester) async { + await _pumpClock(tester, now: () => DateTime(2026, 8, 8, 18, 5), use24Hour: false); + + // CLDR separates the day period with a narrow no-break space, so match the + // shape rather than pinning the separator codepoint. + expect(_clockText(tester), matches(RegExp(r'^6:05\s?PM$'))); + }); + + testWidgets('follows the system format flipping while mounted', (tester) async { + DateTime now() => DateTime(2026, 8, 8, 18, 5); + await _pumpClock(tester, now: now, use24Hour: false); + expect(_clockText(tester), matches(RegExp(r'^6:05\s?PM$'))); + + await _pumpClock(tester, now: now, use24Hour: true); + + expect(_clockText(tester), '18:05'); + }); + + testWidgets('advances when the wall clock crosses a minute boundary', (tester) async { + var now = DateTime(2026, 8, 8, 18, 5, 30); + await _pumpClock(tester, now: () => now, use24Hour: true); + expect(find.text('18:05'), findsOneWidget); + + // Nothing scheduled before the boundary: the label is still the old minute + // 29 seconds later. + now = DateTime(2026, 8, 8, 18, 5, 59); + await tester.pump(const Duration(seconds: 29)); + expect(find.text('18:05'), findsOneWidget); + + now = DateTime(2026, 8, 8, 18, 6, 0); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('18:06'), findsOneWidget); + + // And it re-arms rather than firing once. + now = DateTime(2026, 8, 8, 18, 7, 0); + await tester.pump(const Duration(minutes: 1)); + expect(find.text('18:07'), findsOneWidget); + }); + + testWidgets('stops ticking once it leaves the tree', (tester) async { + await _pumpClock(tester, now: () => DateTime(2026, 8, 8, 18, 5), use24Hour: true); + + await tester.pumpWidget(const SizedBox.shrink()); + + // A surviving timer would trip the binding's pending-timer invariant. + await tester.pump(const Duration(minutes: 5)); + expect(find.byType(SystemClock), findsNothing); + }); +} + +Future _pumpClock(WidgetTester tester, {required DateTime Function() now, required bool use24Hour}) { + return tester.pumpWidget( + MediaQuery( + data: MediaQueryData(alwaysUse24HourFormat: use24Hour), + child: Directionality( + textDirection: TextDirection.ltr, + child: SystemClock(now: now), + ), + ), + ); +} + +String _clockText(WidgetTester tester) => + tester.widget(find.descendant(of: find.byType(SystemClock), matching: find.byType(Text))).data!; diff --git a/test/widgets/video_controls_header_test.dart b/test/widgets/video_controls_header_test.dart index fd338d8d..5efda6f5 100644 --- a/test/widgets/video_controls_header_test.dart +++ b/test/widgets/video_controls_header_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_item.dart'; @@ -11,7 +12,10 @@ import 'package:provider/provider.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); + setUpAll(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + }); testWidgets('title-less mapped movie builds with localized fallback in both layouts', (tester) async { final item = _mappedItem({'Id': 'movie-without-name', 'Type': 'Movie'});