diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 4b4a10b3..97c29817 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -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, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 853d34aa..b484ee15 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -15,6 +15,7 @@ AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson( 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, 'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks, + 'playerMode': instance.playerMode, 'smartQueueEnabled': instance.smartQueueEnabled, 'embedMetadata': instance.embedMetadata, 'embedLyrics': instance.embedLyrics, diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 60731989..41eca8b8 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -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 { !_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 { } } + Future _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 _fetchLyricsForItem(PlaybackItem item) async { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 18a6a9f0..7bad99bc 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -284,6 +284,12 @@ class SettingsNotifier extends Notifier { _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(); diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index dbd4669c..6a33934c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -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( + 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,