From 575f3fc1bda06f6699df2dd82346375ee7a5f64f Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:13:02 +0700 Subject: [PATCH] fix(ios): preserve Now Playing ownership with motion artwork Restore the non-mixing music audio session after decorative video initialization and before playback resumes. Add an artwork switch that releases or pauses video decoders and uses still covers when disabled. --- lib/l10n/arb/app_en.arb | 2 + lib/l10n/arb/app_id.arb | 4 +- lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + .../player_artwork_video_provider.dart | 2 + .../player_motion_artwork_provider.dart | 4 + lib/providers/settings_provider.dart | 5 + lib/screens/now_playing_screen.dart | 5 +- .../settings/playback_settings_page.dart | 19 ++++ .../settings/settings_search_catalog.dart | 6 + lib/services/music_player_service.dart | 20 ++++ lib/widgets/mini_player.dart | 6 +- lib/widgets/mornye_player_artwork.dart | 4 +- lib/widgets/motion_header_banner.dart | 23 +++- test/motion_header_banner_test.dart | 103 +++++++++++++----- 15 files changed, 177 insertions(+), 32 deletions(-) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 96df59d0..3c1c3243 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7033,11 +7033,13 @@ "artistUpcomingConcerts": "Upcoming Concerts", "artistAllUpcomingConcerts": "All Upcoming Concerts", "artistNoUpcomingConcerts": "No upcoming concerts.", + "motionArtwork": "Animated artwork", "trackEditAudioFile": "Edit audio file", "relatedLibraryTracks": "Similar songs in Library", "relatedLibraryDescription": "Songs you already have with related artist, album or genre tags. No online music is searched.", "relatedLibraryEmpty": "No related songs found in your Library.", "relatedLibraryLoadError": "Could not find related songs. Try again.", + "motionArtworkDescription": "Loop video covers supplied by metadata providers. Turn off to use still artwork and reduce battery use.", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index ee0d0c04..d6781a44 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6952,9 +6952,11 @@ "hiResCheckImagingEvidence": "Spektrum memiliki pola pita tercermin yang konsisten dengan upsampling.", "hiResCheckRateEvidence": "Penurunan tajam di sekitar {cutoff} dan noise floor yang terukur mengindikasikan sumber dengan sample rate lebih rendah.", "hiResCheckSteadyEnergy": "Energi di atas {cutoff} relatif konstan pada bagian yang dianalisis dan mungkin berupa noise.", + "motionArtwork": "Artwork bergerak", "trackEditAudioFile": "Ubah file audio", "relatedLibraryTracks": "Lagu serupa di Library", "relatedLibraryDescription": "Lagu yang sudah Anda miliki dengan tag artis, album, atau genre terkait. Tidak mencari musik online.", "relatedLibraryEmpty": "Tidak ada lagu terkait di Library Anda.", - "relatedLibraryLoadError": "Tidak dapat mencari lagu terkait. Coba lagi." + "relatedLibraryLoadError": "Tidak dapat mencari lagu terkait. Coba lagi.", + "motionArtworkDescription": "Putar berulang cover video dari penyedia metadata. Matikan untuk memakai cover diam dan menghemat baterai." } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 208397d8..9a5d966f 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -82,6 +82,7 @@ class AppSettings { final String locale; final String lyricsMode; final bool keepScreenOnLyrics; + final bool motionArtworkEnabled; final bool autoConvertDownloads; // Re-download a Hi-Res request at LOSSLESS when the file measures as fake. final bool redownloadFakeHiRes; @@ -187,6 +188,7 @@ class AppSettings { this.locale = 'system', this.lyricsMode = 'embed', this.keepScreenOnLyrics = true, + this.motionArtworkEnabled = true, this.autoConvertDownloads = false, this.redownloadFakeHiRes = false, this.autoConvertFormat = 'mp3', @@ -284,6 +286,7 @@ class AppSettings { String? locale, String? lyricsMode, bool? keepScreenOnLyrics, + bool? motionArtworkEnabled, bool? autoConvertDownloads, bool? redownloadFakeHiRes, String? autoConvertFormat, @@ -386,6 +389,7 @@ class AppSettings { locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, keepScreenOnLyrics: keepScreenOnLyrics ?? this.keepScreenOnLyrics, + motionArtworkEnabled: motionArtworkEnabled ?? this.motionArtworkEnabled, 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 64a4dc32..1d8b5851 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -68,6 +68,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', keepScreenOnLyrics: json['keepScreenOnLyrics'] as bool? ?? true, + motionArtworkEnabled: json['motionArtworkEnabled'] as bool? ?? true, autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false, redownloadFakeHiRes: json['redownloadFakeHiRes'] as bool? ?? false, autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3', @@ -166,6 +167,7 @@ Map _$AppSettingsToJson( 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'keepScreenOnLyrics': instance.keepScreenOnLyrics, + 'motionArtworkEnabled': instance.motionArtworkEnabled, 'autoConvertDownloads': instance.autoConvertDownloads, 'redownloadFakeHiRes': instance.redownloadFakeHiRes, 'autoConvertFormat': instance.autoConvertFormat, diff --git a/lib/providers/player_artwork_video_provider.dart b/lib/providers/player_artwork_video_provider.dart index 84e48fca..77bf9378 100644 --- a/lib/providers/player_artwork_video_provider.dart +++ b/lib/providers/player_artwork_video_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:video_player/video_player.dart'; /// The mini player prepares the current offline cover, paused and silent. @@ -22,5 +23,6 @@ final playerArtworkVideoProvider = FutureProvider.autoDispose await controller.setVolume(0); if (!ref.mounted) return controller; await controller.setLooping(true); + await restoreMusicAudioSessionAfterVideo(); return controller; }); diff --git a/lib/providers/player_motion_artwork_provider.dart b/lib/providers/player_motion_artwork_provider.dart index d7c7e982..c7ef7d57 100644 --- a/lib/providers/player_motion_artwork_provider.dart +++ b/lib/providers/player_motion_artwork_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/motion_artwork_store.dart'; typedef PlayerArtworkAlbum = ({String album, String artist}); @@ -9,6 +10,9 @@ final motionArtworkStoreProvider = Provider((ref) => MotionArtworkStore()); /// Opening either must never search extensions or download more artwork. final playerMotionArtworkProvider = FutureProvider.autoDispose .family((ref, album) async { + if (!ref.watch(settingsProvider.select((s) => s.motionArtworkEnabled))) { + return null; + } if (album.album.trim().isEmpty || album.artist.trim().isEmpty) { return null; } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 14f1b35c..123329a2 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -618,6 +618,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setMotionArtworkEnabled(bool enabled) { + state = state.copyWith(motionArtworkEnabled: 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 7ac8bab3..04da7330 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -607,7 +607,10 @@ class _NowPlayingScreenState extends ConsumerState { } final source = mediaItem.extras?['source']?.toString() ?? ''; - final motionState = mornye && !MediaQuery.disableAnimationsOf(context) + final motionState = + mornye && + ref.watch(settingsProvider.select((s) => s.motionArtworkEnabled)) && + !MediaQuery.disableAnimationsOf(context) ? ref.watch( playerMotionArtworkProvider(( album: mediaItem.album ?? '', diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart index da6f60a8..0a048934 100644 --- a/lib/screens/settings/playback_settings_page.dart +++ b/lib/screens/settings/playback_settings_page.dart @@ -111,6 +111,25 @@ class _PlaybackSettingsPageState extends ConsumerState { ], ), ), + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.motionArtwork), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.motion_photos_on_outlined, + title: context.l10n.motionArtwork, + subtitle: context.l10n.motionArtworkDescription, + value: settings.motionArtworkEnabled, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setMotionArtworkEnabled(value), + showDivider: false, + ), + ], + ), + ), if (Platform.isAndroid) SliverToBoxAdapter( child: SettingsSectionHeader( diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 2cf8ca96..8596b7ad 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -226,6 +226,12 @@ class SettingsSearchCatalog { ), ], playback = [ + SettingsSearchEntry( + icon: Icons.motion_photos_on_outlined, + title: l10n.motionArtwork, + subtitle: l10n.motionArtworkDescription, + keywords: const ['canvas', 'video', 'animation'], + ), SettingsSearchEntry( icon: Icons.open_in_new, title: l10n.libraryExternalPlayer, diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 26474e29..d9acfa7b 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -90,6 +90,21 @@ Future _publishIosNotificationFavorite() async { } } +/// Decorative video plugins share AVAudioSession with the music player and +/// may enable mixing while creating a muted cover. Restore music ownership +/// without activating a session when this app is not playing music. +Future restoreMusicAudioSessionAfterVideo() async { + if (!Platform.isIOS) return; + final handler = _activeMusicPlayerHandler; + if (handler == null || handler.mediaItem.value == null) return; + try { + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); + } catch (error) { + _log.w('Could not restore music audio session after video: $error'); + } +} + /// Enables/disables ReplayGain volume normalization and re-applies it to the /// track currently playing. void setPlaybackNormalizationEnabled(bool enabled) { @@ -613,6 +628,11 @@ class MusicPlayerHandler extends BaseAudioHandler try { final session = _audioSession ?? await AudioSession.instance; _audioSession = session; + // Other media plugins can change the process-wide iOS category/options + // after initialization. Mixing makes us ineligible for Now Playing. + if (Platform.isIOS) { + await session.configure(const AudioSessionConfiguration.music()); + } final granted = await session.setActive(true); if (!granted) { _log.w('Audio focus request was not granted'); diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index f9d7d19c..ed0fb87b 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; import 'package:spotiflac_android/providers/player_artwork_video_provider.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; @@ -69,7 +70,10 @@ class _MiniPlayerState extends ConsumerState { // re-filters the blurred chrome around it. final fullEffects = !mornye || ref.watch(mornyeLiquidGlassProvider); final marqueeCycles = fullEffects ? null : 2; - if (mornye && fullEffects && !MediaQuery.disableAnimationsOf(context)) { + if (mornye && + fullEffects && + ref.watch(settingsProvider.select((s) => s.motionArtworkEnabled)) && + !MediaQuery.disableAnimationsOf(context)) { final artwork = ref .watch( playerMotionArtworkProvider(( diff --git a/lib/widgets/mornye_player_artwork.dart b/lib/widgets/mornye_player_artwork.dart index 5680c74e..dfaa0acb 100644 --- a/lib/widgets/mornye_player_artwork.dart +++ b/lib/widgets/mornye_player_artwork.dart @@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/player_artwork_video_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/motion_header_banner.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:video_player/video_player.dart'; @@ -47,7 +48,8 @@ class _MornyePlayerArtworkState extends ConsumerState { ), ), ); - if (MediaQuery.disableAnimationsOf(context)) { + if (MediaQuery.disableAnimationsOf(context) || + !ref.watch(settingsProvider.select((s) => s.motionArtworkEnabled))) { _displayedSource = null; _displayedController = null; return fallback; diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart index d71ee914..3d8f9cec 100644 --- a/lib/widgets/motion_header_banner.dart +++ b/lib/widgets/motion_header_banner.dart @@ -2,12 +2,15 @@ import 'dart:io'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:video_player/video_player.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; final _log = AppLogger('MotionHeaderBanner'); -class MotionHeaderBanner extends StatefulWidget { +class MotionHeaderBanner extends ConsumerStatefulWidget { final String videoUrl; final Widget fallback; final BoxFit fit; @@ -30,10 +33,10 @@ class MotionHeaderBanner extends StatefulWidget { }); @override - State createState() => _MotionHeaderBannerState(); + ConsumerState createState() => _MotionHeaderBannerState(); } -class _MotionHeaderBannerState extends State +class _MotionHeaderBannerState extends ConsumerState with WidgetsBindingObserver { VideoPlayerController? _controller; bool _ownsController = true; @@ -46,6 +49,15 @@ class _MotionHeaderBannerState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + ref.listenManual(settingsProvider.select((s) => s.motionArtworkEnabled), ( + _, + enabled, + ) { + _disposeController(); + _ready = false; + _failed = false; + if (enabled) _initialize(); + }); _initialize(); } @@ -101,6 +113,7 @@ class _MotionHeaderBannerState extends State } Future _initialize() async { + if (!ref.read(settingsProvider).motionArtworkEnabled) return; final prepared = widget.controller; if (prepared != null && prepared.value.isInitialized) { _ownsController = false; @@ -140,6 +153,7 @@ class _MotionHeaderBannerState extends State if (!mounted || !identical(controller, _controller)) return; await controller.setVolume(0); await controller.setLooping(true); + await restoreMusicAudioSessionAfterVideo(); if (!mounted || !identical(controller, _controller)) return; setState(() => _ready = true); widget.onAspectRatioChanged?.call(controller.value.aspectRatio); @@ -177,6 +191,9 @@ class _MotionHeaderBannerState extends State @override Widget build(BuildContext context) { + if (!ref.watch(settingsProvider.select((s) => s.motionArtworkEnabled))) { + return widget.fallback; + } final controller = _controller; final showVideo = _ready && !_failed && controller != null; diff --git a/test/motion_header_banner_test.dart b/test/motion_header_banner_test.dart index 280e3992..847004d7 100644 --- a/test/motion_header_banner_test.dart +++ b/test/motion_header_banner_test.dart @@ -10,6 +10,8 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/player_artwork_video_provider.dart'; import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/services/library_database.dart'; @@ -87,7 +89,52 @@ class _VideoPlatform extends VideoPlayerPlatform { } } +class _MotionSettings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} + void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + testWidgets('artwork switch prevents decoding and disposes active video', ( + tester, + ) async { + final previous = VideoPlayerPlatform.instance; + final platform = _VideoPlatform(); + VideoPlayerPlatform.instance = platform; + addTearDown(() => VideoPlayerPlatform.instance = previous); + final container = ProviderContainer( + overrides: [settingsProvider.overrideWith(_MotionSettings.new)], + ); + addTearDown(container.dispose); + container.read(settingsProvider.notifier).setMotionArtworkEnabled(false); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: MotionHeaderBanner( + videoUrl: 'file:///cover.mp4', + fallback: Text('Still artwork'), + ), + ), + ), + ); + await tester.pump(); + expect(platform.creations, 0); + expect(find.text('Still artwork'), findsOneWidget); + container.read(settingsProvider.notifier).setMotionArtworkEnabled(true); + await tester.pumpAndSettle(); + expect(platform.creations, 1); + expect(platform.playing, isTrue); + container.read(settingsProvider.notifier).setMotionArtworkEnabled(false); + await tester.pumpAndSettle(); + expect(find.text('Still artwork'), findsOneWidget); + expect(tester.takeException(), isNull); + // Controller disposal awaits cancellation of the platform event stream. + await tester.runAsync(() async => Future.delayed(Duration.zero)); + expect(platform.disposals, 1); + await tester.pumpWidget(const SizedBox()); + }); for (final downloaded in [false, true]) { for (final mode in ['saved', 'missing', 'reduced motion']) { testWidgets( @@ -312,14 +359,17 @@ void main() { addTearDown(() => VideoPlayerPlatform.instance = previousPlatform); double? ratio; await tester.pumpWidget( - MaterialApp( - home: SizedBox( - width: 320, - height: 180, - child: MotionHeaderBanner( - videoUrl: 'file:///app/motion_artwork/cover.mp4', - fallback: const ColoredBox(color: Colors.black), - onAspectRatioChanged: (value) => ratio = value, + ProviderScope( + overrides: [settingsProvider.overrideWith(_MotionSettings.new)], + child: MaterialApp( + home: SizedBox( + width: 320, + height: 180, + child: MotionHeaderBanner( + videoUrl: 'file:///app/motion_artwork/cover.mp4', + fallback: const ColoredBox(color: Colors.black), + onAspectRatioChanged: (value) => ratio = value, + ), ), ), ), @@ -344,25 +394,28 @@ void main() { final scroll = ScrollController(); addTearDown(scroll.dispose); - Widget app({bool active = true}) => MaterialApp( - home: Scaffold( - body: TickerMode( - enabled: active, - child: CustomScrollView( - controller: scroll, - slivers: const [ - SliverAppBar( - pinned: true, - expandedHeight: 300, - flexibleSpace: FlexibleSpaceBar( - background: MotionHeaderBanner( - videoUrl: 'https://example.com/banner.m3u8', - fallback: ColoredBox(color: Colors.blue), + Widget app({bool active = true}) => ProviderScope( + overrides: [settingsProvider.overrideWith(_MotionSettings.new)], + child: MaterialApp( + home: Scaffold( + body: TickerMode( + enabled: active, + child: CustomScrollView( + controller: scroll, + slivers: const [ + SliverAppBar( + pinned: true, + expandedHeight: 300, + flexibleSpace: FlexibleSpaceBar( + background: MotionHeaderBanner( + videoUrl: 'https://example.com/banner.m3u8', + fallback: ColoredBox(color: Colors.blue), + ), ), ), - ), - SliverToBoxAdapter(child: SizedBox(height: 2000)), - ], + SliverToBoxAdapter(child: SizedBox(height: 2000)), + ], + ), ), ), ),