mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 06:09:39 +02:00
feat(lyrics): keep the screen awake while viewing lyrics
This commit is contained in:
1 parent
a1cec6de51
commit
8aa8d598d8
14 files changed
+276
No files matched your search
@@ -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<Boolean>("enabled") == true) {
|
||||
window.addFlags(flag)
|
||||
} else {
|
||||
window.clearFlags(flag)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"consumeVerificationNotification" -> {
|
||||
val payload = pendingVerificationNotification
|
||||
pendingVerificationNotification = null
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,6 +67,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
|
||||
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'keepScreenOnLyrics': instance.keepScreenOnLyrics,
|
||||
'autoConvertDownloads': instance.autoConvertDownloads,
|
||||
'redownloadFakeHiRes': instance.redownloadFakeHiRes,
|
||||
'autoConvertFormat': instance.autoConvertFormat,
|
||||
|
||||
@@ -613,6 +613,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<NowPlayingScreen> {
|
||||
|
||||
@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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -173,6 +173,15 @@ class PlatformBridge {
|
||||
static const int notificationPercentTotal = 10000;
|
||||
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
|
||||
static Future<void> setScreenAwake(bool enabled) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS) {
|
||||
return;
|
||||
}
|
||||
await _channel.invokeMethod<void>('setScreenAwake', {'enabled': enabled});
|
||||
}
|
||||
|
||||
static const _jsonResultFileKey = '__json_file';
|
||||
static const _backgroundJsonDecodeThresholdBytes = 128 * 1024;
|
||||
static const _metadataCacheTtl = Duration(minutes: 20);
|
||||
|
||||
@@ -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<LyricsScreenAwake> createState() => _LyricsScreenAwakeState();
|
||||
}
|
||||
|
||||
class _LyricsScreenAwakeState extends ConsumerState<LyricsScreenAwake>
|
||||
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<void> _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();
|
||||
}
|
||||
}
|
||||
@@ -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<bool> requests;
|
||||
|
||||
setUp(() {
|
||||
requests = [];
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
|
||||
call,
|
||||
) async {
|
||||
if (call.method == 'setScreenAwake') {
|
||||
requests.add(
|
||||
(call.arguments as Map<Object?, Object?>)['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<NavigatorState>();
|
||||
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<void>(
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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}');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user