mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(player): add verified USB bit-perfect PCM output
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"usbBitPerfect": "USB bit-perfect audio",
|
||||
"usbBitPerfectDescription": "Preserve the original PCM audio on compatible USB DACs with Android 14 or later. Applies from the next track, bypassing AutoMix and normalization. Control volume on your DAC; system volume may not apply.",
|
||||
"usbBitPerfectPending": "Waiting for the next track and a compatible USB output.",
|
||||
"usbBitPerfectNoDevice": "No USB DAC connected. Using normal audio output.",
|
||||
"usbBitPerfectFallback": "This device or audio format does not support bit-perfect output. Using normal playback.",
|
||||
"usbBitPerfectRouteChanged": "The USB audio route changed. Playback paused; select a track to reconnect.",
|
||||
"upgradeIntroWelcome": "Welcome to\nSpotiFLAC-Mobile 5.0",
|
||||
"upgradeIntroThemeTitle": "Make it\nyour own.",
|
||||
"upgradeIntroThemeBody": "Choose a look. Your choice applies immediately, and you can change it later in Appearance.",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"usbBitPerfect": "Audio USB bit-perfect",
|
||||
"usbBitPerfectDescription": "Pertahankan PCM asli pada DAC USB yang kompatibel dengan Android 14 atau lebih baru. Berlaku mulai lagu berikutnya, tanpa AutoMix dan normalisasi. Atur volume melalui DAC; volume sistem mungkin tidak berlaku.",
|
||||
"usbBitPerfectPending": "Menunggu lagu berikutnya dan keluaran USB yang kompatibel.",
|
||||
"usbBitPerfectNoDevice": "DAC USB tidak terhubung. Menggunakan keluaran audio biasa.",
|
||||
"usbBitPerfectFallback": "Perangkat atau format audio ini tidak mendukung keluaran bit-perfect. Menggunakan pemutaran biasa.",
|
||||
"usbBitPerfectRouteChanged": "Keluaran audio USB berubah. Pemutaran dijeda; pilih lagu untuk menyambungkan kembali.",
|
||||
"upgradeIntroWelcome": "Selamat datang di\nSpotiFLAC-Mobile 5.0",
|
||||
"upgradeIntroThemeTitle": "Pilih\ntampilanmu.",
|
||||
"upgradeIntroThemeBody": "Pilih tema yang kamu suka. Pilihan langsung diterapkan dan bisa diubah lagi lewat Tampilan.",
|
||||
|
||||
@@ -32,6 +32,7 @@ class AppSettings {
|
||||
// Apply ReplayGain/R128 tags as volume normalization in the built-in player.
|
||||
final bool playbackNormalization;
|
||||
final bool autoMix;
|
||||
final bool usbBitPerfect;
|
||||
final bool autoplay;
|
||||
final bool isFirstLaunch;
|
||||
final bool checkForUpdates;
|
||||
@@ -145,6 +146,7 @@ class AppSettings {
|
||||
this.embedReplayGain = false,
|
||||
this.playbackNormalization = false,
|
||||
this.autoMix = false,
|
||||
this.usbBitPerfect = false,
|
||||
this.autoplay = false,
|
||||
this.isFirstLaunch = true,
|
||||
this.checkForUpdates = true,
|
||||
@@ -233,6 +235,7 @@ class AppSettings {
|
||||
bool? embedReplayGain,
|
||||
bool? playbackNormalization,
|
||||
bool? autoMix,
|
||||
bool? usbBitPerfect,
|
||||
bool? autoplay,
|
||||
bool? isFirstLaunch,
|
||||
bool? checkForUpdates,
|
||||
@@ -317,6 +320,7 @@ class AppSettings {
|
||||
playbackNormalization:
|
||||
playbackNormalization ?? this.playbackNormalization,
|
||||
autoMix: autoMix ?? this.autoMix,
|
||||
usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect,
|
||||
autoplay: autoplay ?? this.autoplay,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
|
||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
|
||||
playbackNormalization: json['playbackNormalization'] as bool? ?? false,
|
||||
autoMix: json['autoMix'] as bool? ?? false,
|
||||
usbBitPerfect: json['usbBitPerfect'] as bool? ?? false,
|
||||
autoplay: json['autoplay'] as bool? ?? false,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
@@ -123,6 +124,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'embedReplayGain': instance.embedReplayGain,
|
||||
'playbackNormalization': instance.playbackNormalization,
|
||||
'autoMix': instance.autoMix,
|
||||
'usbBitPerfect': instance.usbBitPerfect,
|
||||
'autoplay': instance.autoplay,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
|
||||
@@ -561,6 +561,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUsbBitPerfect(bool enabled) {
|
||||
state = state.copyWith(usbBitPerfect: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedMetadata(bool enabled) {
|
||||
state = state.copyWith(embedMetadata: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -92,6 +92,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
ref.read(settingsProvider).playbackNormalization,
|
||||
);
|
||||
setAutoMixEnabled(ref.read(settingsProvider).autoMix);
|
||||
setUsbBitPerfectEnabled(ref.read(settingsProvider).usbBitPerfect);
|
||||
setAutoplayEnabled(
|
||||
ref.read(settingsProvider).autoplay,
|
||||
includeLocal: ref.read(settingsProvider).localLibraryEnabled,
|
||||
@@ -742,6 +743,9 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) {
|
||||
setAutoMixEnabled(enabled);
|
||||
});
|
||||
ref.listen(settingsProvider.select((s) => s.usbBitPerfect), (_, enabled) {
|
||||
setUsbBitPerfectEnabled(enabled);
|
||||
});
|
||||
ref.listen(
|
||||
settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)),
|
||||
(_, value) => setAutoplayEnabled(value.$1, includeLocal: value.$2),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart';
|
||||
@@ -880,6 +881,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
title: context.l10n.libraryPlaybackNormalization,
|
||||
subtitle: context.l10n.libraryPlaybackNormalizationSubtitle,
|
||||
value: settings.playbackNormalization,
|
||||
enabled: !settings.usbBitPerfect,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setPlaybackNormalization(v),
|
||||
@@ -889,10 +891,39 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
title: 'AutoMix',
|
||||
subtitle: context.l10n.autoMixDescription,
|
||||
value: settings.autoMix,
|
||||
enabled: !settings.usbBitPerfect,
|
||||
onChanged: (value) =>
|
||||
ref.read(settingsProvider.notifier).setAutoMix(value),
|
||||
showDivider: false,
|
||||
showDivider: Platform.isAndroid,
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
ValueListenableBuilder<UsbAudioStatus>(
|
||||
valueListenable: usbAudioStatus,
|
||||
builder: (context, status, _) => SettingsSwitchItem(
|
||||
icon: Icons.usb,
|
||||
title: context.l10n.usbBitPerfect,
|
||||
subtitle: [
|
||||
context.l10n.usbBitPerfectDescription,
|
||||
if (settings.usbBitPerfect)
|
||||
switch (status.reason) {
|
||||
'active' =>
|
||||
'${status.device} · ${status.bitDepth}-bit · ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz',
|
||||
'no_usb' => context.l10n.usbBitPerfectNoDevice,
|
||||
'route_changed' =>
|
||||
context.l10n.usbBitPerfectRouteChanged,
|
||||
'android_version' ||
|
||||
'unsupported' ||
|
||||
'format' => context.l10n.usbBitPerfectFallback,
|
||||
_ => context.l10n.usbBitPerfectPending,
|
||||
},
|
||||
].join('\n\n'),
|
||||
value: settings.usbBitPerfect,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUsbBitPerfect(value),
|
||||
showDivider: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Only verified native playback reports active. File metadata alone never does.
|
||||
final usbAudioStatus = ValueNotifier<UsbAudioStatus>(const UsbAudioStatus());
|
||||
|
||||
class UsbAudioStatus {
|
||||
const UsbAudioStatus({
|
||||
this.reason = 'idle',
|
||||
this.device,
|
||||
this.sampleRate,
|
||||
this.bitDepth,
|
||||
});
|
||||
|
||||
final String reason;
|
||||
final String? device;
|
||||
final int? sampleRate;
|
||||
final int? bitDepth;
|
||||
}
|
||||
|
||||
/// Keeps the existing transport and AutoMix API while adding a local USB path.
|
||||
/// Each prepare owns a token so delayed native events cannot affect a new song.
|
||||
class MusicPlaybackDeck {
|
||||
MusicPlaybackDeck({required String playerId, bool? nativeAvailable})
|
||||
: _ordinary = AudioPlayer(playerId: playerId),
|
||||
_nativeAvailable = nativeAvailable ?? Platform.isAndroid {
|
||||
_subscriptions.addAll([
|
||||
_ordinary.onPlayerStateChanged.listen((value) {
|
||||
if (!_direct) _states.add(value);
|
||||
}),
|
||||
_ordinary.onDurationChanged.listen((value) {
|
||||
if (!_direct) _durations.add(value);
|
||||
}),
|
||||
_ordinary.onPlayerComplete.listen((_) {
|
||||
if (!_direct) _completions.add(null);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/usb_pcm');
|
||||
static const _events = EventChannel('com.zarz.spotiflac/usb_pcm/events');
|
||||
static Stream<dynamic>? _nativeEvents;
|
||||
static int _nextToken = 0;
|
||||
final AudioPlayer _ordinary;
|
||||
final bool _nativeAvailable;
|
||||
final _states = StreamController<PlayerState>.broadcast();
|
||||
final _durations = StreamController<Duration>.broadcast();
|
||||
final _positions = StreamController<Duration>.broadcast();
|
||||
final _completions = StreamController<void>.broadcast();
|
||||
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||
StreamSubscription<dynamic>? _nativeSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
Timer? _timer;
|
||||
bool _polling = false;
|
||||
bool _direct = false;
|
||||
bool _disposed = false;
|
||||
int _token = 0;
|
||||
Duration? _duration;
|
||||
DeviceFileSource? _source;
|
||||
bool _routeLost = false;
|
||||
bool _nativeStarted = false;
|
||||
PlayerState _directState = PlayerState.stopped;
|
||||
UsbAudioStatus _preparedStatus = const UsbAudioStatus();
|
||||
|
||||
bool get isDirect => _direct;
|
||||
bool get needsSourceReload => _direct && _routeLost;
|
||||
PlayerState get state => _direct ? _directState : _ordinary.state;
|
||||
Stream<PlayerState> get onPlayerStateChanged => _states.stream;
|
||||
Stream<Duration> get onDurationChanged => _durations.stream;
|
||||
Stream<Duration> get onPositionChanged => _positions.stream;
|
||||
Stream<void> get onPlayerComplete => _completions.stream;
|
||||
|
||||
set positionUpdater(PositionUpdater updater) {
|
||||
_ordinary.positionUpdater = updater;
|
||||
unawaited(_positionSubscription?.cancel());
|
||||
_positionSubscription = _ordinary.onPositionChanged.listen((value) {
|
||||
if (!_direct) _positions.add(value);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setReleaseMode(ReleaseMode mode) =>
|
||||
_ordinary.setReleaseMode(mode);
|
||||
Future<void> setAudioContext(AudioContext context) =>
|
||||
_ordinary.setAudioContext(context);
|
||||
Future<void> setVolume(double volume) async {
|
||||
if (!_direct) await _ordinary.setVolume(volume);
|
||||
}
|
||||
|
||||
Future<void> setPlaybackRate(double rate) async {
|
||||
if (!_direct) await _ordinary.setPlaybackRate(rate);
|
||||
}
|
||||
|
||||
Future<void> setSource(
|
||||
DeviceFileSource source, {
|
||||
bool preferBitPerfect = false,
|
||||
}) async {
|
||||
await stop();
|
||||
_source = source;
|
||||
_routeLost = false;
|
||||
_nativeStarted = false;
|
||||
if (preferBitPerfect && _nativeAvailable) {
|
||||
_token = ++_nextToken;
|
||||
_nativeSubscription ??=
|
||||
(_nativeEvents ??= _events.receiveBroadcastStream()).listen(
|
||||
_onNativeEvent,
|
||||
onError: (Object error) {
|
||||
if (_direct && !_disposed) {
|
||||
_routeLost = true;
|
||||
unawaited(pause().catchError((Object _) {}));
|
||||
usbAudioStatus.value = const UsbAudioStatus(
|
||||
reason: 'route_changed',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
try {
|
||||
final response = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'prepare',
|
||||
{'path': source.path, 'token': _token},
|
||||
);
|
||||
if (response?['ready'] == true) {
|
||||
_direct = true;
|
||||
_directState = PlayerState.stopped;
|
||||
_duration = Duration(
|
||||
milliseconds: (response?['duration'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
_preparedStatus = UsbAudioStatus(
|
||||
reason: 'active',
|
||||
device: response?['device'] as String?,
|
||||
sampleRate: (response?['sampleRate'] as num?)?.toInt(),
|
||||
bitDepth: (response?['bitDepth'] as num?)?.toInt(),
|
||||
);
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'ready');
|
||||
_durations.add(_duration!);
|
||||
return;
|
||||
}
|
||||
usbAudioStatus.value = UsbAudioStatus(
|
||||
reason: response?['reason'] as String? ?? 'unsupported',
|
||||
);
|
||||
} on PlatformException {
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
|
||||
} on MissingPluginException {
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
|
||||
}
|
||||
}
|
||||
await _ordinary.setSource(source);
|
||||
}
|
||||
|
||||
void _onNativeEvent(dynamic event) {
|
||||
if (_disposed || !_direct || event is! Map || event['token'] != _token) {
|
||||
return;
|
||||
}
|
||||
switch (event['event']) {
|
||||
case 'active':
|
||||
usbAudioStatus.value = _preparedStatus;
|
||||
case 'paused':
|
||||
_routeLost = true;
|
||||
_timer?.cancel();
|
||||
_setState(PlayerState.paused);
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
|
||||
case 'complete':
|
||||
_timer?.cancel();
|
||||
_setState(PlayerState.completed);
|
||||
_completions.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
void _setState(PlayerState value) {
|
||||
_directState = value;
|
||||
if (!_disposed) _states.add(value);
|
||||
}
|
||||
|
||||
Future<void> resume() async {
|
||||
if (!_direct) return _ordinary.resume();
|
||||
final token = _token;
|
||||
// Before first playback the SAF lease is still open. After playback starts,
|
||||
// reconnect through the handler to obtain a fresh lease for the same song.
|
||||
try {
|
||||
if (_routeLost) throw PlatformException(code: 'route_changed');
|
||||
await _channel.invokeMethod<void>('resume');
|
||||
} on PlatformException {
|
||||
if (token != _token || !_direct || _disposed) return;
|
||||
if (_nativeStarted) {
|
||||
_routeLost = true;
|
||||
_setState(PlayerState.paused);
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
|
||||
rethrow;
|
||||
}
|
||||
final source = _source;
|
||||
final position = await getCurrentPosition() ?? Duration.zero;
|
||||
await stop();
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
|
||||
if (source == null || _disposed) return;
|
||||
await _ordinary.setVolume(1);
|
||||
await _ordinary.setSource(source);
|
||||
if (position > Duration.zero) await _ordinary.seek(position);
|
||||
return _ordinary.resume();
|
||||
}
|
||||
if (_disposed || token != _token || !_direct) return;
|
||||
_nativeStarted = true;
|
||||
_setState(PlayerState.playing);
|
||||
usbAudioStatus.value = _preparedStatus;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) async {
|
||||
if (_polling || _disposed) return;
|
||||
_polling = true;
|
||||
final token = _token;
|
||||
try {
|
||||
final position = await getCurrentPosition();
|
||||
if (!_disposed && _direct && token == _token && position != null) {
|
||||
_positions.add(position);
|
||||
}
|
||||
} on PlatformException {
|
||||
// Route failures arrive through the native event stream.
|
||||
} finally {
|
||||
_polling = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
if (!_direct) return _ordinary.pause();
|
||||
final token = _token;
|
||||
_timer?.cancel();
|
||||
await _channel.invokeMethod<void>('pause');
|
||||
if (token == _token && _direct) _setState(PlayerState.paused);
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
if (!_direct) return _ordinary.seek(position);
|
||||
final token = _token;
|
||||
await _channel.invokeMethod<void>('seek', {
|
||||
'position': position.inMilliseconds,
|
||||
});
|
||||
if (!_disposed && token == _token && _direct) _positions.add(position);
|
||||
}
|
||||
|
||||
Future<Duration?> getCurrentPosition() async {
|
||||
if (!_direct) return _ordinary.getCurrentPosition();
|
||||
final milliseconds = await _channel.invokeMethod<int>('position');
|
||||
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
|
||||
}
|
||||
|
||||
Future<Duration?> getDuration() =>
|
||||
_direct ? Future.value(_duration) : _ordinary.getDuration();
|
||||
|
||||
Future<void> stop() async {
|
||||
_timer?.cancel();
|
||||
if (_direct) {
|
||||
_token = ++_nextToken;
|
||||
await _channel.invokeMethod<void>('stop');
|
||||
_setState(PlayerState.stopped);
|
||||
_direct = false;
|
||||
usbAudioStatus.value = const UsbAudioStatus();
|
||||
}
|
||||
await _ordinary.stop();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await stop();
|
||||
_disposed = true;
|
||||
await _nativeSubscription?.cancel();
|
||||
await _positionSubscription?.cancel();
|
||||
for (final subscription in _subscriptions) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
await _ordinary.dispose();
|
||||
await _states.close();
|
||||
await _durations.close();
|
||||
await _positions.close();
|
||||
await _completions.close();
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ class _MusicAutoMix {
|
||||
final AutoMixAnalyzer analyzer;
|
||||
final _preparationPins = <int, Set<String>>{};
|
||||
final _mixPins = <String>{};
|
||||
AudioPlayer? _prepared;
|
||||
AudioPlayer? _outgoing;
|
||||
MusicPlaybackDeck? _prepared;
|
||||
MusicPlaybackDeck? _outgoing;
|
||||
AutoMixPlan? _plan;
|
||||
PlayableMedia? _next;
|
||||
String? _nextPath;
|
||||
@@ -37,6 +37,8 @@ class _MusicAutoMix {
|
||||
|
||||
bool get _canMix =>
|
||||
_autoMixEnabled &&
|
||||
!_usbBitPerfectEnabled &&
|
||||
!handler._player.isDirect &&
|
||||
!handler._disposed &&
|
||||
handler._sourceReady &&
|
||||
!handler._userPaused &&
|
||||
@@ -91,7 +93,7 @@ class _MusicAutoMix {
|
||||
: -1;
|
||||
final pins = <String>{};
|
||||
_preparationPins[generation] = pins;
|
||||
AudioPlayer? deck;
|
||||
MusicPlaybackDeck? deck;
|
||||
try {
|
||||
if (nextIndex < 0 || nextIndex == index || index < 0) return;
|
||||
final current = handler._media[index];
|
||||
@@ -102,7 +104,7 @@ class _MusicAutoMix {
|
||||
final nextPath = await handler._resolveSource(next);
|
||||
if (nextPath == null || !_current(generation)) return;
|
||||
pins.add(nextPath);
|
||||
deck = AudioPlayer(
|
||||
deck = MusicPlaybackDeck(
|
||||
playerId:
|
||||
'music-mix-$generation-${DateTime.now().microsecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/sqlite_helpers.dart'
|
||||
show normalizeLookupText;
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/playback_normalization.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/services/automix_analysis.dart';
|
||||
import 'package:spotiflac_android/services/automix_analyzer.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
@@ -42,6 +43,7 @@ void updateMusicPlayerStrings({
|
||||
|
||||
bool _playbackNormalizationEnabled = false;
|
||||
bool _autoMixEnabled = false;
|
||||
bool _usbBitPerfectEnabled = false;
|
||||
MusicPlayerHandler? _activeMusicPlayerHandler;
|
||||
PlaybackNotification _notificationPresentation = const PlaybackNotification();
|
||||
Future<void> Function(MediaItem)? _toggleNotificationFavorite;
|
||||
@@ -99,6 +101,14 @@ void setAutoMixEnabled(bool enabled) {
|
||||
if (handler != null) unawaited(handler._autoMix.cancel());
|
||||
}
|
||||
|
||||
void setUsbBitPerfectEnabled(bool enabled) {
|
||||
if (_usbBitPerfectEnabled == enabled) return;
|
||||
_usbBitPerfectEnabled = enabled;
|
||||
final handler = _activeMusicPlayerHandler;
|
||||
if (handler != null) unawaited(handler._autoMix.cancel());
|
||||
// Apply at the next source boundary; never raise a playing track's volume.
|
||||
}
|
||||
|
||||
/// Refreshes gain tags after a successful file update, including SAF copies.
|
||||
void refreshPlaybackNormalization(String source) {
|
||||
final handler = _activeMusicPlayerHandler;
|
||||
@@ -352,13 +362,13 @@ Duration normalizedPlaybackResumePosition(
|
||||
|
||||
class MusicPlayerHandler extends BaseAudioHandler
|
||||
with QueueHandler, SeekHandler {
|
||||
AudioPlayer _player = AudioPlayer(playerId: 'music-player');
|
||||
MusicPlaybackDeck _player = MusicPlaybackDeck(playerId: 'music-player');
|
||||
late final _MusicAutoMix _autoMix;
|
||||
late final _MusicAutoplay _autoplay;
|
||||
bool _completing = false;
|
||||
double _normalizationVolume = 1;
|
||||
final _playerSubscriptions =
|
||||
<AudioPlayer, List<StreamSubscription<dynamic>>>{};
|
||||
<MusicPlaybackDeck, List<StreamSubscription<dynamic>>>{};
|
||||
AudioSession? _audioSession;
|
||||
final List<PlayableMedia> _media = [];
|
||||
final List<MediaItem> _queueItems = [];
|
||||
@@ -433,7 +443,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_listenToPlayer(_player);
|
||||
}
|
||||
|
||||
void _listenToPlayer(AudioPlayer player) {
|
||||
void _listenToPlayer(MusicPlaybackDeck player) {
|
||||
// Position updates must continue with the display asleep. UI progress is
|
||||
// interpolated between updates; querying native playback every frame is
|
||||
// unnecessary and would stop preparing AutoMix when there are no frames.
|
||||
@@ -444,6 +454,10 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_playerSubscriptions[player] = [
|
||||
player.onPlayerStateChanged.listen((state) {
|
||||
if (!identical(player, _player)) return;
|
||||
if (state == PlayerState.paused && player.needsSourceReload) {
|
||||
_userPaused = true;
|
||||
_pausedByInterruption = false;
|
||||
}
|
||||
if (_switchingGeneration != 0 &&
|
||||
(state == PlayerState.stopped ||
|
||||
state == PlayerState.completed ||
|
||||
@@ -474,7 +488,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _disposeDeck(AudioPlayer player) async {
|
||||
Future<void> _disposeDeck(MusicPlaybackDeck player) async {
|
||||
final subscriptions = _playerSubscriptions.remove(player);
|
||||
if (subscriptions == null) return;
|
||||
for (final subscription in subscriptions) {
|
||||
@@ -764,7 +778,11 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
String? cacheKey,
|
||||
String? displayName,
|
||||
}) async {
|
||||
if (!_playbackNormalizationEnabled) return 1.0;
|
||||
if (!_playbackNormalizationEnabled ||
|
||||
_usbBitPerfectEnabled ||
|
||||
_player.isDirect) {
|
||||
return 1.0;
|
||||
}
|
||||
return _normalizationCache.volumeFor(
|
||||
path,
|
||||
cacheKey: cacheKey,
|
||||
@@ -1347,7 +1365,10 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
// AudioPlayer.play combines prepare/seek/resume without a cancellation
|
||||
// boundary. A late prepare from an old request could resume the wrong
|
||||
// source. Serialize preparation and recheck before making it audible.
|
||||
await _player.setSource(DeviceFileSource(path));
|
||||
await _player.setSource(
|
||||
DeviceFileSource(path),
|
||||
preferBitPerfect: _usbBitPerfectEnabled,
|
||||
);
|
||||
if (!_isCurrentPlayRequest(generation, media)) return;
|
||||
if (position != null) {
|
||||
await _player.seek(position);
|
||||
@@ -1494,7 +1515,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
mediaItem.add(media.toMediaItem(resolvedSource: resolved));
|
||||
}
|
||||
_broadcastPosition(effectiveStartPosition, force: true);
|
||||
_broadcastState(playerState: PlayerState.playing);
|
||||
_broadcastState();
|
||||
_lastPeriodicPersistAt = DateTime.now();
|
||||
unawaited(_persistSession(position: effectiveStartPosition));
|
||||
// Some files do not emit onDurationChanged reliably (stuck at 0:00);
|
||||
@@ -1613,6 +1634,15 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_pausedByInterruption = false;
|
||||
_interruptionActive = false;
|
||||
_userPaused = false;
|
||||
if (_player.needsSourceReload) {
|
||||
final generation = _playRequestGeneration;
|
||||
final index = _index;
|
||||
final position =
|
||||
await _player.getCurrentPosition() ?? playbackState.value.position;
|
||||
if (generation != _playRequestGeneration || _disposed) return;
|
||||
await _playIndex(index, recordHistory: false, startPosition: position);
|
||||
return;
|
||||
}
|
||||
if ((!_sourceReady ||
|
||||
_player.state == PlayerState.stopped ||
|
||||
_player.state == PlayerState.completed) &&
|
||||
@@ -1627,7 +1657,13 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
}
|
||||
await _activateAudioSession();
|
||||
await _claimHardwareMediaButtons();
|
||||
await _player.resume();
|
||||
try {
|
||||
await _player.resume();
|
||||
} on PlatformException {
|
||||
if (!_player.needsSourceReload) rethrow;
|
||||
_broadcastState(playerState: PlayerState.paused);
|
||||
return;
|
||||
}
|
||||
_broadcastState(playerState: PlayerState.playing);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user