From 8aa8d598d8bf3cc4ce8cfae3b4583129c3cc170b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:21:21 +0700 Subject: [PATCH] feat(lyrics): keep the screen awake while viewing lyrics --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 10 ++ ios/Runner/AppDelegate.swift | 6 + lib/l10n/arb/app_en.arb | 2 + lib/l10n/arb/app_id.arb | 2 + lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 + lib/screens/now_playing_screen.dart | 10 ++ .../settings/lyrics_settings_page.dart | 9 ++ .../settings/settings_search_catalog.dart | 6 + lib/services/platform_bridge.dart | 9 ++ lib/widgets/lyrics_screen_awake.dart | 78 +++++++++++ test/lyrics_screen_awake_test.dart | 132 ++++++++++++++++++ test/now_playing_lyrics_transition_test.dart | 1 + 14 files changed, 276 insertions(+) create mode 100644 lib/widgets/lyrics_screen_awake.dart create mode 100644 test/lyrics_screen_awake_test.dart diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index e28990aa..3aa24419 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -81,6 +81,7 @@ class MainActivity: FlutterFragmentActivity() { private var concertCalendarChannel: MethodChannel? = null internal val coreBackend: CoreBackend by lazy { createCoreBackend(applicationContext) } private val nativeBackendMethods = setOf( + "setScreenAwake", "getBackendImplementations", "ensureInstallMarker", "prepareRuntimeState", @@ -1080,6 +1081,15 @@ class MainActivity: FlutterFragmentActivity() { return@launch } when (call.method) { + "setScreenAwake" -> { + val flag = android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + if (call.argument("enabled") == true) { + window.addFlags(flag) + } else { + window.clearFlags(flag) + } + result.success(null) + } "consumeVerificationNotification" -> { val payload = pendingVerificationNotification pendingVerificationNotification = null diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5ff66f72..771f5524 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -262,6 +262,12 @@ import UniformTypeIdentifiers "getLibraryScanProgress", "cancelLibraryScan", "parseCueSheet", "extractCoverToFile", "rewriteSplitArtistTags", "writeM4AFreeformTags", "ensureAC4Config", "writeAC4Metadata", "reEnrichFile", "checkHiResAuthenticity"] + if call.method == "setScreenAwake" { + let args = call.arguments as? [String: Any] + UIApplication.shared.isIdleTimerDisabled = args?["enabled"] as? Bool ?? false + result(nil) + return + } if coreBackend.routesApplication && !osMethods.contains(call.method) { DispatchQueue.global(qos: .userInitiated).async { do { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 66ad2051..3af51f9d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1544,6 +1544,8 @@ "@sectionLyrics": { "description": "Settings section header" }, + "lyricsKeepScreenOn": "Keep screen on while viewing lyrics", + "lyricsKeepScreenOnSubtitle": "Prevent screen timeout while lyrics are open, including when playback is paused.", "lyricsMode": "Lyrics Mode", "@lyricsMode": { "description": "Setting - how to save lyrics" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index b348b8b7..9098e3fc 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1512,6 +1512,8 @@ "@sectionLyrics": { "description": "Settings section header" }, + "lyricsKeepScreenOn": "Layar tetap menyala saat melihat lirik", + "lyricsKeepScreenOnSubtitle": "Cegah layar mati otomatis selama lirik terbuka, termasuk saat lagu dijeda.", "lyricsMode": "Mode Lirik", "@lyricsMode": { "description": "Setting - how to save lyrics" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index b5635a9e..732b767a 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -81,6 +81,7 @@ class AppSettings { extensionVerificationBrowserMode; // 'external_first' or 'in_app_first' final String locale; final String lyricsMode; + final bool keepScreenOnLyrics; final bool autoConvertDownloads; // Re-download a Hi-Res request at LOSSLESS when the file measures as fake. final bool redownloadFakeHiRes; @@ -185,6 +186,7 @@ class AppSettings { this.extensionVerificationBrowserMode = 'in_app_first', this.locale = 'system', this.lyricsMode = 'embed', + this.keepScreenOnLyrics = true, this.autoConvertDownloads = false, this.redownloadFakeHiRes = false, this.autoConvertFormat = 'mp3', @@ -281,6 +283,7 @@ class AppSettings { String? extensionVerificationBrowserMode, String? locale, String? lyricsMode, + bool? keepScreenOnLyrics, bool? autoConvertDownloads, bool? redownloadFakeHiRes, String? autoConvertFormat, @@ -382,6 +385,7 @@ class AppSettings { this.extensionVerificationBrowserMode, locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, + keepScreenOnLyrics: keepScreenOnLyrics ?? this.keepScreenOnLyrics, autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads, redownloadFakeHiRes: redownloadFakeHiRes ?? this.redownloadFakeHiRes, autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index d514ea95..64a4dc32 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -67,6 +67,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first', locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', + keepScreenOnLyrics: json['keepScreenOnLyrics'] as bool? ?? true, autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false, redownloadFakeHiRes: json['redownloadFakeHiRes'] as bool? ?? false, autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3', @@ -164,6 +165,7 @@ Map _$AppSettingsToJson( 'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode, 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, + 'keepScreenOnLyrics': instance.keepScreenOnLyrics, 'autoConvertDownloads': instance.autoConvertDownloads, 'redownloadFakeHiRes': instance.redownloadFakeHiRes, 'autoConvertFormat': instance.autoConvertFormat, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index eafee5bd..088fdc27 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -613,6 +613,11 @@ class SettingsNotifier extends Notifier { } } + void setKeepScreenOnLyrics(bool enabled) { + state = state.copyWith(keepScreenOnLyrics: enabled); + _saveSettings(); + } + void setLyricsMode(String mode) { if (mode == 'embed' || mode == 'external' || mode == 'both') { state = state.copyWith(lyricsMode: mode); diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index fdcd0626..33104366 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -42,6 +42,7 @@ import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/audio_output_button.dart'; import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; +import 'package:spotiflac_android/widgets/lyrics_screen_awake.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/player_queue_dismissible.dart'; import 'package:spotiflac_android/widgets/player_track_swipe.dart'; @@ -549,6 +550,15 @@ class _NowPlayingScreenState extends ConsumerState { @override Widget build(BuildContext context) { + return LyricsScreenAwake( + visible: + _currentPage == 1 && + ref.watch(currentMediaItemProvider).value != null, + child: _buildPlayer(context), + ); + } + + Widget _buildPlayer(BuildContext context) { final mornye = context.isMornye; final colorScheme = mornye ? MornyeTheme.fromContext( diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index 01773d9a..9c9bd66f 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -34,6 +34,15 @@ class LyricsSettingsPage extends ConsumerWidget { SliverToBoxAdapter( child: SettingsGroup( children: [ + SettingsSwitchItem( + icon: Icons.light_mode_outlined, + title: context.l10n.lyricsKeepScreenOn, + subtitle: context.l10n.lyricsKeepScreenOnSubtitle, + value: settings.keepScreenOnLyrics, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setKeepScreenOnLyrics(value), + ), SettingsSwitchItem( icon: Icons.subtitles_outlined, title: context.l10n.optionsEmbedLyrics, diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 660c6885..5462acc1 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -307,6 +307,12 @@ class SettingsSearchCatalog { ), ], lyrics = [ + SettingsSearchEntry( + icon: Icons.light_mode_outlined, + title: l10n.lyricsKeepScreenOn, + subtitle: l10n.lyricsKeepScreenOnSubtitle, + keywords: const ['screen timeout', 'awake', 'sleep', 'layar'], + ), SettingsSearchEntry( icon: Icons.subtitles_outlined, title: l10n.optionsEmbedLyrics, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 433b4e74..12d53095 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -173,6 +173,15 @@ class PlatformBridge { static const int notificationPercentTotal = 10000; static const _channel = MethodChannel('com.zarz.spotiflac/backend'); + + static Future setScreenAwake(bool enabled) async { + if (defaultTargetPlatform != TargetPlatform.android && + defaultTargetPlatform != TargetPlatform.iOS) { + return; + } + await _channel.invokeMethod('setScreenAwake', {'enabled': enabled}); + } + static const _jsonResultFileKey = '__json_file'; static const _backgroundJsonDecodeThresholdBytes = 128 * 1024; static const _metadataCacheTtl = Duration(minutes: 20); diff --git a/lib/widgets/lyrics_screen_awake.dart b/lib/widgets/lyrics_screen_awake.dart new file mode 100644 index 00000000..1b67d1d0 --- /dev/null +++ b/lib/widgets/lyrics_screen_awake.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('LyricsScreenAwake'); + +/// Holds only the visible lyrics screen awake, never background audio playback. +class LyricsScreenAwake extends ConsumerStatefulWidget { + const LyricsScreenAwake({ + required this.visible, + required this.child, + super.key, + }); + + final bool visible; + final Widget child; + + @override + ConsumerState createState() => _LyricsScreenAwakeState(); +} + +class _LyricsScreenAwakeState extends ConsumerState + with WidgetsBindingObserver { + bool _wanted = false; + bool _requested = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + void _update({bool force = false}) { + final lifecycle = WidgetsBinding.instance.lifecycleState; + final enabled = + _wanted && + (lifecycle == null || lifecycle == AppLifecycleState.resumed); + if (!force && enabled == _requested) return; + _requested = enabled; + unawaited(_setAwake(enabled)); + } + + Future _setAwake(bool enabled) async { + try { + await PlatformBridge.setScreenAwake(enabled); + } catch (error) { + _log.w('Could not update screen timeout: $error'); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Reapply after resuming: Android may have recreated the Activity/window. + _update(force: state == AppLifecycleState.resumed); + } + + @override + Widget build(BuildContext context) { + final enabled = ref.watch( + settingsProvider.select((settings) => settings.keepScreenOnLyrics), + ); + _wanted = + widget.visible && enabled && (ModalRoute.isCurrentOf(context) ?? true); + _update(); + return widget.child; + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + if (_requested) unawaited(_setAwake(false)); + super.dispose(); + } +} diff --git a/test/lyrics_screen_awake_test.dart b/test/lyrics_screen_awake_test.dart new file mode 100644 index 00000000..0ebd784b --- /dev/null +++ b/test/lyrics_screen_awake_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/lyrics_screen_awake.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); + + @override + void setKeepScreenOnLyrics(bool enabled) { + state = state.copyWith(keepScreenOnLyrics: enabled); + } +} + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + late List requests; + + setUp(() { + requests = []; + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'setScreenAwake') { + requests.add( + (call.arguments as Map)['enabled']! as bool, + ); + } + return null; + }); + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + }); + + tearDown(() { + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + test('screen preference defaults on and survives settings round trips', () { + expect(AppSettings.fromJson({}).keepScreenOnLyrics, isTrue); + for (final enabled in [false, true]) { + final settings = const AppSettings().copyWith( + keepScreenOnLyrics: enabled, + ); + expect( + AppSettings.fromJson(settings.toJson()).keepScreenOnLyrics, + enabled, + ); + } + }); + + testWidgets('only visible lyrics hold the screen awake and release on exit', ( + tester, + ) async { + final visible = ValueNotifier(false); + addTearDown(visible.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [settingsProvider.overrideWith(_Settings.new)], + child: MaterialApp( + home: ValueListenableBuilder( + valueListenable: visible, + builder: (context, value, _) => + LyricsScreenAwake(visible: value, child: const SizedBox()), + ), + ), + ), + ); + expect(requests, isEmpty); + visible.value = true; + await tester.pump(); + expect(requests, [true]); + await tester.pump(); + expect(requests, [ + true, + ], reason: 'No repeated requests on unchanged frames'); + visible.value = false; + await tester.pump(); + expect(requests, [true, false]); + visible.value = true; + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + expect(requests, [true, false, true, false]); + }); + + testWidgets( + 'releases in background, under another route, and when disabled', + (tester) async { + final navigator = GlobalKey(); + final container = ProviderContainer( + overrides: [settingsProvider.overrideWith(_Settings.new)], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + navigatorKey: navigator, + home: const LyricsScreenAwake(visible: true, child: SizedBox()), + ), + ), + ); + expect(requests.last, isTrue); + binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + expect(requests.last, isFalse); + binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(requests.last, isTrue); + navigator.currentState!.push( + MaterialPageRoute(builder: (_) => const Scaffold()), + ); + await tester.pumpAndSettle(); + expect(requests.last, isFalse); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(requests.last, isTrue); + container.read(settingsProvider.notifier).setKeepScreenOnLyrics(false); + await tester.pump(); + expect(requests.last, isFalse); + binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(requests.last, isFalse); + }, + ); +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index c5d2d80d..c12ba199 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -63,6 +63,7 @@ void main() { .setMockMethodCallHandler(secureStorageChannel, (_) async => null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(backendChannel, (call) async { + if (call.method == 'setScreenAwake') return null; if (call.method != 'readFileMetadata') { fail('Unexpected platform call: ${call.method}'); }