diff --git a/lib/services/music_haptics.dart b/lib/services/music_haptics.dart index 12d6eadc..4c926ab4 100644 --- a/lib/services/music_haptics.dart +++ b/lib/services/music_haptics.dart @@ -4,11 +4,13 @@ import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart'; import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart' show AppLogger; const musicHapticsSampleRate = 4000; @@ -174,6 +176,18 @@ class MusicHapticsAnalyzer { static Future<({int id, Future completed})> _start( List arguments, ) async { + final inputIndex = arguments.indexOf('-i') + 1; + if (inputIndex > 0 && arguments[inputIndex].startsWith('content://')) { + // Resolve only when this owned job starts. FFmpegKit opens and closes + // its own SAF descriptor, independently of the player's short lease. + final input = await FFmpegKitConfig.getSafParameterForRead( + arguments[inputIndex], + ); + if (input == null || input.isEmpty) { + throw StateError('Unable to open haptics audio source'); + } + arguments = List.of(arguments)..[inputIndex] = input; + } final done = Completer(); final session = await FFmpegKit.executeWithArgumentsAsync(arguments, ( session, @@ -206,11 +220,28 @@ class MusicHapticsAnalyzer { final generation = _generation; Directory? work; try { - final stat = await File(path).stat(); - if (stat.type != FileSystemEntityType.file) return null; - final key = '$path:${stat.size}:${stat.modified.microsecondsSinceEpoch}'; - final cached = _cache.remove(key); - if (cached != null) return _cache[key] = cached; + String? key; + if (path.startsWith('content://')) { + // A document URI is not a filesystem path. Unknown modification times + // still allow analysis, but cannot safely identify a cached revision. + try { + final modified = (await PlatformBridge.getSafFileModTimes([ + path, + ]))[path]; + if (modified != null && modified > 0) key = '$path:$modified'; + } catch (_) { + // Some document providers cannot report a modification timestamp. + } + } else { + final stat = await File(path).stat(); + if (stat.type != FileSystemEntityType.file) return null; + key = '$path:${stat.size}:${stat.modified.microsecondsSinceEpoch}'; + } + if (generation != _generation) return null; + if (key != null) { + final cached = _cache.remove(key); + if (cached != null) return _cache[key] = cached; + } work = await (await _temporaryDirectory()).createTemp('haptics-'); if (generation != _generation) return null; final output = File('${work.path}/song.pcm'); @@ -248,7 +279,7 @@ class MusicHapticsAnalyzer { } final track = await compute(analyzeHapticPcm, await output.readAsBytes()); if (generation != _generation) return null; - _cache[key] = track; + if (key != null) _cache[key] = track; while (_cache.length > 8) { _cache.remove(_cache.keys.first); } diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 839d0559..bbe0b551 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -576,10 +576,13 @@ class MusicPlayerHandler extends BaseAudioHandler final extras = mediaItem.value?.extras; final source = extras?['source']?.toString() ?? ''; final resolved = extras?['resolvedSource']?.toString().trim() ?? ''; - final path = resolved.isNotEmpty ? resolved : source; + final document = source.startsWith('content://'); + // Use a stable document identity for both direct playback and AutoMix. + // Haptics opens its own SAF input; the player's descriptor is short-lived. + final path = document ? source : (resolved.isNotEmpty ? resolved : source); final local = path.isNotEmpty && - !path.contains('://') && + (document || !path.contains('://')) && _settings.musicHaptics && _settings.playerMode == 'internal'; _haptics.update(local ? path : null); diff --git a/test/music_haptics_test.dart b/test/music_haptics_test.dart index fb990b56..33605e97 100644 --- a/test/music_haptics_test.dart +++ b/test/music_haptics_test.dart @@ -1,8 +1,13 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:audio_service/audio_service.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; import 'package:spotiflac_android/services/music_haptics.dart'; Uint8List _pcm(double seconds, double Function(double time) sample) { @@ -35,7 +40,16 @@ class _Analyzer extends MusicHapticsAnalyzer { } } +class _PendingAnalyzer extends MusicHapticsAnalyzer { + final pending = >{}; + + @override + Future analyze(String path) => + (pending[path] = Completer()).future; +} + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); test('a kick on every beat becomes one deep pulse per beat', () { final track = analyzeHapticPcm(_pcm(6, _hits(60, 0.5, 0.25))); expect(track.length, inInclusiveRange(11, 12)); @@ -98,4 +112,136 @@ void main() { expect(analyzer.paths, ['/music/song.flac']); haptics.dispose(); }); + + test( + 'analysis finished during loading starts on the first ready state', + () async { + var state = PlaybackState( + playing: true, + processingState: AudioProcessingState.loading, + ); + var pulses = 0; + final analyzer = _Analyzer( + HapticTrack( + Int32List.fromList([80]), + Uint8List.fromList([255]), + Uint8List.fromList([0]), + ), + ); + final haptics = MusicHaptics( + state: () => state, + analyzer: analyzer, + pulse: (_, _) async => pulses++, + ); + addTearDown(haptics.dispose); + haptics.update('/music/first.flac'); + await Future.delayed(Duration.zero); + expect(haptics.track, isNotNull); + expect(pulses, 0); + state = PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + ); + haptics.update('/music/first.flac'); + await Future.delayed(const Duration(milliseconds: 150)); + expect(pulses, 1); + expect(analyzer.paths, ['/music/first.flac']); + }, + ); + + test( + 'late analysis from a skipped song cannot replace the active pulses', + () async { + final state = PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + ); + final analyzer = _PendingAnalyzer(); + final haptics = MusicHaptics( + state: () => state, + analyzer: analyzer, + pulse: (_, _) async {}, + ); + addTearDown(haptics.dispose); + haptics.update('content://music/one'); + haptics.update('content://music/two'); + final latest = HapticTrack(Int32List(0), Uint8List(0), Uint8List(0)); + analyzer.pending['content://music/two']!.complete(latest); + await Future.delayed(Duration.zero); + analyzer.pending['content://music/one']!.complete( + HapticTrack( + Int32List.fromList([80]), + Uint8List.fromList([255]), + Uint8List(1), + ), + ); + await Future.delayed(Duration.zero); + expect(haptics.track, same(latest)); + }, + ); + + group('document analysis', () { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Directory temporary; + late MusicHapticsAnalyzer analyzer; + late List inputs; + var modified = 1; + + setUp(() async { + temporary = await Directory.systemTemp.createTemp('haptics-test-'); + inputs = []; + modified = 1; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getSafFileModTimes'); + final source = + (jsonDecode((call.arguments as Map)['uris'] as String) as List) + .single + as String; + return {source: modified}; + }); + analyzer = MusicHapticsAnalyzer( + temporaryDirectory: () async => temporary, + jobs: AudioAnalysisJobs( + start: (arguments) async { + inputs.add(arguments[arguments.indexOf('-i') + 1]); + await File( + arguments.last, + ).writeAsBytes(_pcm(2, _hits(60, 0.5, 0.25))); + return (id: inputs.length, completed: Future.value(true)); + }, + cancel: (_) async {}, + ), + ); + }); + tearDown(() async { + analyzer.dispose(); + messenger.setMockMethodCallHandler(channel, null); + await temporary.delete(recursive: true); + }); + + test('document URI is decoded without a playback cache copy', () async { + const source = 'content://music/document/first.flac'; + final first = await analyzer.analyze(source); + expect(first?.length, greaterThan(0)); + expect(await analyzer.analyze(source), same(first)); + expect(inputs, [source]); + modified++; + expect(await analyzer.analyze(source), isNot(same(first))); + expect(inputs, [source, source]); + expect(await temporary.list().toList(), isEmpty); + }); + + test( + 'unknown modification time permits analysis without caching stale pulses', + () async { + modified = 0; + const source = 'content://music/document/unknown.flac'; + expect((await analyzer.analyze(source))?.length, greaterThan(0)); + expect((await analyzer.analyze(source))?.length, greaterThan(0)); + expect(inputs, [source, source]); + }, + ); + }); } diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index 9841e710..94bc00df 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'package:audio_service/audio_service.dart'; import 'package:audioplayers/audioplayers.dart'; @@ -10,6 +11,7 @@ import 'package:spotiflac_android/services/automix_analysis.dart'; import 'package:spotiflac_android/services/automix_analyzer.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/music_playback_deck.dart'; +import 'package:spotiflac_android/services/music_haptics.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/services/playback_notification.dart'; import 'package:spotiflac_android/models/automix_options.dart'; @@ -56,6 +58,20 @@ class _Analyzer extends AutoMixAnalyzer { } } +class _HapticsAnalyzer extends MusicHapticsAnalyzer { + final paths = []; + + @override + Future analyze(String path) async { + paths.add(path); + return HapticTrack( + Int32List.fromList(List.generate(20, (index) => (index + 1) * 150)), + Uint8List.fromList(List.filled(20, 255)), + Uint8List(20), + ); + } +} + /// Exercises the real AudioPlayer transport/streams against a native-channel /// fake, including preparation, seek completion and outgoing completion events. class _AudioNative { @@ -219,8 +235,13 @@ void main() { late _EffectRenderer effectRenderer; late MusicPlayerHandler handler; late AutoplayLibraryLoader autoplayLoader; + late _HapticsAnalyzer hapticsAnalyzer; + late List hapticSongs; setUp(() { + musicPlayerRuntime.configure( + musicPlayerRuntime.settings.copyWith(musicHaptics: false), + ); setPlaybackAutomationOptions( enabled: true, pauseOnMute: false, @@ -234,10 +255,14 @@ void main() { analyzer = _Analyzer(); effectRenderer = _EffectRenderer(); autoplayLoader = (_) async => _tracks; + hapticsAnalyzer = _HapticsAnalyzer(); + hapticSongs = []; handler = MusicPlayerHandler( autoMixAnalyzer: analyzer, autoMixEffectRenderer: effectRenderer, autoplayLibraryLoader: (seed) => autoplayLoader(seed), + hapticsAnalyzer: hapticsAnalyzer, + hapticPulse: (_, _) async => hapticSongs.add(handler.mediaItem.value?.id), ); }); @@ -311,6 +336,77 @@ void main() { }, ); + group('music haptics transport', () { + void enableHaptics() => musicPlayerRuntime.configure( + musicPlayerRuntime.settings.copyWith(musicHaptics: true), + ); + + test('direct documents work without AutoMix resolved metadata', () async { + enableHaptics(); + const source = 'content://music/document/first.flac'; + handler.mediaItem.add( + const MediaItem( + id: 'document', + title: 'Document', + extras: {'source': source}, + ), + ); + handler.playbackState.add( + PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + ), + ); + await _until(() => hapticSongs.isNotEmpty); + expect(hapticSongs.first, 'document'); + expect(hapticsAnalyzer.paths, [source]); + // A subsequent AutoMix-style metadata update keeps the same analysis. + handler.mediaItem.add( + handler.mediaItem.value!.copyWith( + extras: {'source': source, 'resolvedSource': '/cache/first.flac'}, + ), + ); + await Future.delayed(const Duration(milliseconds: 20)); + expect(hapticsAnalyzer.paths, [source]); + }); + + for (final autoMix in [false, true]) { + test( + 'first song and manual skips retain haptics (AutoMix: $autoMix)', + () async { + enableHaptics(); + setAutoMixEnabled(autoMix); + await handler.setQueueAndPlay(_tracks); + await _until(() => hapticSongs.contains('one')); + await handler.skipToNext(); + await _until(() => hapticSongs.contains('two')); + final beforePrevious = hapticSongs.length; + await handler.skipToPrevious(); + await _until(() => hapticSongs.skip(beforePrevious).contains('one')); + expect(hapticSongs.last, 'one'); + expect(hapticsAnalyzer.paths, [ + '/one.flac', + '/two.flac', + '/one.flac', + ]); + }, + ); + } + + test( + 'ordinary completion and a subsequent skip both keep haptics', + () async { + enableHaptics(); + await handler.setQueueAndPlay(_tracks); + await _until(() => hapticSongs.contains('one')); + await native.event('music-player', 'audio.onComplete'); + await _until(() => hapticSongs.contains('two')); + await handler.skipToNext(); + await _until(() => hapticSongs.contains('three')); + }, + ); + }); + group('playback failures', () { test('runtime error stays on the song and Play retries it', () async { await handler.setQueueAndPlay(_tracks); @@ -1627,6 +1723,23 @@ void main() { return incoming; } + test( + 'haptics survives an AutoMix handoff followed by a manual skip', + () async { + musicPlayerRuntime.configure( + musicPlayerRuntime.settings.copyWith(musicHaptics: true), + ); + await startMix(); + await _until(() => hapticSongs.contains('two')); + await handler.skipToNext(); + await _until(() => hapticSongs.contains('three')); + expect( + hapticsAnalyzer.paths, + containsAllInOrder(['/one.flac', '/two.flac', '/three.flac']), + ); + }, + ); + test('Previous after AutoMix handoff reloads the preceding audio', () async { final active = await startMix(); await handler.seek(Duration.zero);