mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-10 00:23:58 +02:00
feat: add external player mode for local library playback
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user