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.
This commit is contained in:
zarzet committed 2026-10-02 18:13:02 +07:00
1 parent a93e552b3e
commit 575f3fc1bd
15 files changed
+177 -32

No files matched your search

+2
View File
@@ -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"
+3 -1
View File
@@ -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."
}
+4
View File
@@ -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,
+2
View File
@@ -68,6 +68,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'keepScreenOnLyrics': instance.keepScreenOnLyrics,
'motionArtworkEnabled': instance.motionArtworkEnabled,
'autoConvertDownloads': instance.autoConvertDownloads,
'redownloadFakeHiRes': instance.redownloadFakeHiRes,
'autoConvertFormat': instance.autoConvertFormat,
@@ -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;
});
@@ -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<MotionArtwork?, PlayerArtworkAlbum>((ref, album) async {
if (!ref.watch(settingsProvider.select((s) => s.motionArtworkEnabled))) {
return null;
}
if (album.album.trim().isEmpty || album.artist.trim().isEmpty) {
return null;
}
+5
View File
@@ -618,6 +618,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_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);
+4 -1
View File
@@ -607,7 +607,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
}
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 ?? '',
@@ -111,6 +111,25 @@ class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
],
),
),
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(
@@ -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,
+20
View File
@@ -90,6 +90,21 @@ Future<void> _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<void> 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');
+5 -1
View File
@@ -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<MiniPlayer> {
// 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((
+3 -1
View File
@@ -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<MornyePlayerArtwork> {
),
),
);
if (MediaQuery.disableAnimationsOf(context)) {
if (MediaQuery.disableAnimationsOf(context) ||
!ref.watch(settingsProvider.select((s) => s.motionArtworkEnabled))) {
_displayedSource = null;
_displayedController = null;
return fallback;
+20 -3
View File
@@ -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<MotionHeaderBanner> createState() => _MotionHeaderBannerState();
ConsumerState<MotionHeaderBanner> createState() => _MotionHeaderBannerState();
}
class _MotionHeaderBannerState extends State<MotionHeaderBanner>
class _MotionHeaderBannerState extends ConsumerState<MotionHeaderBanner>
with WidgetsBindingObserver {
VideoPlayerController? _controller;
bool _ownsController = true;
@@ -46,6 +49,15 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
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<MotionHeaderBanner>
}
Future<void> _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<MotionHeaderBanner>
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<MotionHeaderBanner>
@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;
+78 -25
View File
@@ -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<void>.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)),
],
),
),
),
),