feat: add external player mode for local library playback

This commit is contained in:
zarzet
2026-02-27 14:38:45 +07:00
parent b3771f3488
commit 96d11b1d7d
5 changed files with 173 additions and 0 deletions
+4
View File
@@ -12,6 +12,7 @@ class AppSettings {
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
final bool autoSkipUnavailableTracks;
final String playerMode; // 'internal' or 'external'
final bool smartQueueEnabled; // Enable smart curated autoplay queue
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
final bool embedLyrics;
@@ -92,6 +93,7 @@ class AppSettings {
this.downloadTreeUri = '',
this.autoFallback = true,
this.autoSkipUnavailableTracks = true,
this.playerMode = 'internal',
this.smartQueueEnabled = true,
this.embedMetadata = true,
this.embedLyrics = true,
@@ -160,6 +162,7 @@ class AppSettings {
String? downloadTreeUri,
bool? autoFallback,
bool? autoSkipUnavailableTracks,
String? playerMode,
bool? smartQueueEnabled,
bool? embedMetadata,
bool? embedLyrics,
@@ -222,6 +225,7 @@ class AppSettings {
autoFallback: autoFallback ?? this.autoFallback,
autoSkipUnavailableTracks:
autoSkipUnavailableTracks ?? this.autoSkipUnavailableTracks,
playerMode: playerMode ?? this.playerMode,
smartQueueEnabled: smartQueueEnabled ?? this.smartQueueEnabled,
embedMetadata: embedMetadata ?? this.embedMetadata,
embedLyrics: embedLyrics ?? this.embedLyrics,
+2
View File
@@ -15,6 +15,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
autoSkipUnavailableTracks: json['autoSkipUnavailableTracks'] as bool? ?? true,
playerMode: json['playerMode'] as String? ?? 'internal',
smartQueueEnabled: json['smartQueueEnabled'] as bool? ?? true,
embedMetadata: json['embedMetadata'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
@@ -93,6 +94,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback,
'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks,
'playerMode': instance.playerMode,
'smartQueueEnabled': instance.smartQueueEnabled,
'embedMetadata': instance.embedMetadata,
'embedLyrics': instance.embedLyrics,
+83
View File
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -1647,6 +1648,16 @@ class PlaybackController extends Notifier<PlaybackState> {
!_isPlayRequestCurrent(expectedRequestEpoch)) {
return;
}
final handledByExternal = await _tryPlayWithExternalPlayerIfConfigured(
uri: uri,
item: item,
expectedRequestEpoch: expectedRequestEpoch,
);
if (handledByExternal) {
return;
}
final sourceUrl = uri.toString();
await FFmpegService.activatePreparedNativeDashManifest(sourceUrl);
if (!FFmpegService.isActiveLiveDecryptedUrl(sourceUrl)) {
@@ -1719,6 +1730,78 @@ class PlaybackController extends Notifier<PlaybackState> {
}
}
Future<bool> _tryPlayWithExternalPlayerIfConfigured({
required Uri uri,
required PlaybackItem item,
int? expectedRequestEpoch,
}) async {
final settings = ref.read(settingsProvider);
if (settings.playerMode != 'external') return false;
if (!item.isLocal) return false;
final externalPath = _externalPathFromPlaybackUri(uri);
if (externalPath == null || externalPath.isEmpty) return false;
_log.d('Opening with external player: $externalPath');
_updateMediaItemNotification(item);
try {
await openFile(externalPath);
if (expectedRequestEpoch != null &&
!_isPlayRequestCurrent(expectedRequestEpoch)) {
return true;
}
state = state.copyWith(
currentItem: item,
isLoading: false,
isBuffering: false,
isPlaying: false,
seekSupported: false,
position: Duration.zero,
bufferedPosition: Duration.zero,
duration: _fallbackDurationForItem(item),
clearError: true,
);
_syncServicePlaybackState(ProcessingState.idle, false);
unawaited(_savePlaybackSnapshot());
return true;
} catch (e) {
if (expectedRequestEpoch != null &&
!_isPlayRequestCurrent(expectedRequestEpoch)) {
return true;
}
_log.w('External player open failed: $e');
state = state.copyWith(
isLoading: false,
isBuffering: false,
isPlaying: false,
);
_setPlaybackError(
'Failed to open in external player: $e',
type: 'external_player_failed',
);
return true;
}
}
String? _externalPathFromPlaybackUri(Uri uri) {
if (uri.scheme == 'content') {
return uri.toString();
}
if (uri.scheme == 'file') {
try {
return uri.toFilePath();
} catch (_) {
return uri.path.isNotEmpty ? uri.path : null;
}
}
if (!uri.hasScheme) {
final asString = uri.toString().trim();
return asString.isNotEmpty ? asString : null;
}
return null;
}
// ─── Lyrics fetching + parsing ───────────────────────────────────────────
Future<void> _fetchLyricsForItem(PlaybackItem item) async {
+6
View File
@@ -284,6 +284,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setPlayerMode(String mode) {
final normalized = mode == 'external' ? 'external' : 'internal';
state = state.copyWith(playerMode: normalized);
_saveSettings();
}
void setSmartQueueEnabled(bool enabled) {
state = state.copyWith(smartQueueEnabled: enabled);
_saveSettings();
@@ -167,6 +167,16 @@ class OptionsSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier)
.setAutoSkipUnavailableTracks(v),
),
SettingsItem(
icon: Icons.headphones,
title: 'Music Player',
subtitle: _playerModeLabel(settings.playerMode),
onTap: () => _showPlayerModePicker(
context,
ref,
settings.playerMode,
),
),
SettingsSwitchItem(
icon: Icons.queue_music_rounded,
title: context.l10n.settingsSmartQueueTitle,
@@ -318,6 +328,74 @@ class OptionsSettingsPage extends ConsumerWidget {
);
}
String _playerModeLabel(String mode) {
if (mode == 'external') {
return 'External app (Poweramp, etc.)';
}
return 'Internal player';
}
void _showPlayerModePicker(
BuildContext context,
WidgetRef ref,
String currentMode,
) {
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) {
final colorScheme = Theme.of(sheetContext).colorScheme;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(999),
),
),
const SizedBox(height: 12),
ListTile(
leading: const Icon(Icons.play_circle_outline),
title: const Text('Internal Player'),
subtitle: const Text('Use built-in app playback and queue'),
trailing: currentMode == 'internal'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setPlayerMode('internal');
Navigator.pop(sheetContext);
},
),
ListTile(
leading: const Icon(Icons.open_in_new),
title: const Text('External Player'),
subtitle: const Text(
'Open songs with apps like Poweramp, Musicolet, etc.',
),
trailing: currentMode == 'external'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setPlayerMode('external');
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
);
},
);
}
void _showClearHistoryDialog(
BuildContext context,
WidgetRef ref,