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.
This commit is contained in:
@@ -22,6 +22,7 @@ import '../utils/content_utils.dart';
|
|||||||
import '../widgets/cycling_media_backdrop.dart';
|
import '../widgets/cycling_media_backdrop.dart';
|
||||||
import '../widgets/optimized_media_image.dart' show ClearLogoImage, blurArtwork;
|
import '../widgets/optimized_media_image.dart' show ClearLogoImage, blurArtwork;
|
||||||
import '../widgets/toolbar_scrim.dart';
|
import '../widgets/toolbar_scrim.dart';
|
||||||
|
import '../widgets/system_clock.dart';
|
||||||
import '../providers/discover_provider.dart';
|
import '../providers/discover_provider.dart';
|
||||||
import '../providers/multi_server_provider.dart';
|
import '../providers/multi_server_provider.dart';
|
||||||
import '../providers/watch_state_store.dart';
|
import '../providers/watch_state_store.dart';
|
||||||
@@ -748,6 +749,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
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<WatchTogetherProvider, CompanionRemoteProvider>(
|
Consumer2<WatchTogetherProvider, CompanionRemoteProvider>(
|
||||||
builder: (context, watchTogether, companionRemote, _) {
|
builder: (context, watchTogether, companionRemote, _) {
|
||||||
final isDesktop = PlatformDetector.shouldActAsRemoteHost(context);
|
final isDesktop = PlatformDetector.shouldActAsRemoteHost(context);
|
||||||
|
|||||||
@@ -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<SystemClock> createState() => _SystemClockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SystemClockState extends State<SystemClock> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import '../../../i18n/strings.g.dart';
|
|||||||
import '../../../watch_together/widgets/watch_together_overlay.dart';
|
import '../../../watch_together/widgets/watch_together_overlay.dart';
|
||||||
import '../../../watch_together/providers/watch_together_provider.dart';
|
import '../../../watch_together/providers/watch_together_provider.dart';
|
||||||
import '../../app_bar_back_button.dart';
|
import '../../app_bar_back_button.dart';
|
||||||
|
import '../../system_clock.dart';
|
||||||
|
|
||||||
/// Header layout style for video controls
|
/// Header layout style for video controls
|
||||||
enum VideoHeaderStyle {
|
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,
|
?trailing,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:plezy/media/ids.dart';
|
import 'package:plezy/media/ids.dart';
|
||||||
import 'package:flutter/material.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/watch_together/watch_together.dart';
|
||||||
import 'package:plezy/widgets/side_navigation_rail.dart';
|
import 'package:plezy/widgets/side_navigation_rail.dart';
|
||||||
import 'package:plezy/widgets/tv_browse_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:plezy/widgets/tv_spotlight_background.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@ import '../test_helpers/multi_server_fixtures.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() => initializeDateFormatting('en'));
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
resetSharedPreferencesForTest();
|
resetSharedPreferencesForTest();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
@@ -627,6 +631,116 @@ void main() {
|
|||||||
// the binding's pending-timer check.
|
// the binding's pending-timer check.
|
||||||
await tester.pumpWidget(const SizedBox());
|
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<void> _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<MultiServerProvider>.value(value: multiServerProvider),
|
||||||
|
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
|
||||||
|
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
|
||||||
|
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
|
||||||
|
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
|
||||||
|
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
|
||||||
|
ChangeNotifierProvider<DiscoverProvider>.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 {
|
class _FakeMediaServerClient implements MediaServerClient {
|
||||||
|
|||||||
@@ -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<void> _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<Text>(find.descendant(of: find.byType(SystemClock), matching: find.byType(Text))).data!;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/i18n/strings.g.dart';
|
||||||
import 'package:plezy/media/ids.dart';
|
import 'package:plezy/media/ids.dart';
|
||||||
import 'package:plezy/media/media_item.dart';
|
import 'package:plezy/media/media_item.dart';
|
||||||
@@ -11,7 +12,10 @@ import 'package:provider/provider.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
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 {
|
testWidgets('title-less mapped movie builds with localized fallback in both layouts', (tester) async {
|
||||||
final item = _mappedItem({'Id': 'movie-without-name', 'Type': 'Movie'});
|
final item = _mappedItem({'Id': 'movie-without-name', 'Type': 'Movie'});
|
||||||
|
|||||||
Reference in New Issue
Block a user