mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 02:55:36 +02:00
feat: add new playback experience and media integration
This commit is contained in:
@@ -62,6 +62,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
|
||||
@@ -114,6 +114,23 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
@@ -130,6 +147,10 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -17,6 +18,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.ryanheise.audioservice.AudioServicePlugin
|
||||
import gobackend.Gobackend
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -36,6 +38,10 @@ import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
override fun provideFlutterEngine(context: Context): FlutterEngine {
|
||||
return AudioServicePlugin.getFlutterEngine(context)
|
||||
}
|
||||
|
||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
|
||||
"com.zarz.spotiflac/download_progress_stream"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -94,6 +94,8 @@ class AppSettings {
|
||||
deduplicateDownloads;
|
||||
final bool saveDownloadHistory;
|
||||
|
||||
final String playerMode;
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = '',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
@@ -155,6 +157,7 @@ class AppSettings {
|
||||
this.lastSeenVersion = '',
|
||||
this.deduplicateDownloads = true,
|
||||
this.saveDownloadHistory = true,
|
||||
this.playerMode = 'external',
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -221,6 +224,7 @@ class AppSettings {
|
||||
String? lastSeenVersion,
|
||||
bool? deduplicateDownloads,
|
||||
bool? saveDownloadHistory,
|
||||
String? playerMode,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -305,6 +309,7 @@ class AppSettings {
|
||||
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
||||
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
|
||||
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
|
||||
playerMode: playerMode ?? this.playerMode,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
|
||||
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
|
||||
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
|
||||
playerMode: json['playerMode'] as String? ?? 'external',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(
|
||||
@@ -149,4 +150,5 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'lastSeenVersion': instance.lastSeenVersion,
|
||||
'deduplicateDownloads': instance.deduplicateDownloads,
|
||||
'saveDownloadHistory': instance.saveDownloadHistory,
|
||||
'playerMode': instance.playerMode,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
|
||||
final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
|
||||
return musicPlayerMediaItemEvents();
|
||||
});
|
||||
|
||||
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
|
||||
return musicPlayerPlaybackStateEvents();
|
||||
});
|
||||
|
||||
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
|
||||
return musicPlayerQueueEvents();
|
||||
});
|
||||
|
||||
class MusicPlayerController {
|
||||
const MusicPlayerController();
|
||||
|
||||
MusicPlayerHandler? get _handler => musicPlayerHandler;
|
||||
|
||||
bool get isAvailable => _handler != null;
|
||||
|
||||
Future<MusicPlayerHandler?> ensureInitialized() async {
|
||||
try {
|
||||
return await initMusicPlayer();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playAll(
|
||||
List<PlayableMedia> items, {
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
final handler = await ensureInitialized();
|
||||
await handler?.setQueueAndPlay(items, initialIndex: initialIndex);
|
||||
}
|
||||
|
||||
Future<void> playSingle(PlayableMedia item) => playAll([item]);
|
||||
|
||||
Future<void> playHistory(
|
||||
List<DownloadHistoryItem> items, {
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
final media = items
|
||||
.where((i) => i.filePath.trim().isNotEmpty)
|
||||
.map(playableFromHistory)
|
||||
.toList();
|
||||
if (media.isEmpty) return;
|
||||
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
||||
}
|
||||
|
||||
Future<void> playLocal(
|
||||
List<LocalLibraryItem> items, {
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
final media = items
|
||||
.where((i) => i.filePath.trim().isNotEmpty)
|
||||
.map(playableFromLocal)
|
||||
.toList();
|
||||
if (media.isEmpty) return;
|
||||
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
||||
}
|
||||
|
||||
Future<void> play() async => _handler?.play();
|
||||
Future<void> pause() async => _handler?.pause();
|
||||
Future<void> stop() async => _handler?.stop();
|
||||
Future<void> seek(Duration position) async => _handler?.seek(position);
|
||||
Future<void> next() async => _handler?.skipToNext();
|
||||
Future<void> previous() async => _handler?.skipToPrevious();
|
||||
|
||||
Future<void> togglePlayPause(bool isPlaying) async {
|
||||
if (isPlaying) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setShuffle(bool enabled) async {
|
||||
await _handler?.setShuffleMode(
|
||||
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> playNext(PlayableMedia item) async =>
|
||||
(await ensureInitialized())?.enqueue(item, playNext: true);
|
||||
|
||||
Future<void> addToQueue(PlayableMedia item) async =>
|
||||
(await ensureInitialized())?.enqueue(item);
|
||||
|
||||
Future<void> playNextHistory(DownloadHistoryItem item) async =>
|
||||
playNext(playableFromHistory(item));
|
||||
|
||||
Future<void> addToQueueHistory(DownloadHistoryItem item) async =>
|
||||
addToQueue(playableFromHistory(item));
|
||||
|
||||
Future<void> playNextLocal(LocalLibraryItem item) async =>
|
||||
playNext(playableFromLocal(item));
|
||||
|
||||
Future<void> addToQueueLocal(LocalLibraryItem item) async =>
|
||||
addToQueue(playableFromLocal(item));
|
||||
|
||||
Future<void> jumpTo(int index) async => _handler?.skipToQueueItem(index);
|
||||
}
|
||||
|
||||
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
|
||||
(ref) => const MusicPlayerController(),
|
||||
);
|
||||
|
||||
PlayableMedia playableFromHistory(DownloadHistoryItem item) {
|
||||
return PlayableMedia(
|
||||
id: item.id,
|
||||
source: item.filePath,
|
||||
title: item.trackName,
|
||||
artist: item.artistName,
|
||||
album: item.albumName,
|
||||
artUri: (item.coverUrl != null && item.coverUrl!.trim().isNotEmpty)
|
||||
? item.coverUrl
|
||||
: null,
|
||||
duration: (item.duration != null && item.duration! > 0)
|
||||
? Duration(seconds: item.duration!)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
PlayableMedia playableFromLocal(LocalLibraryItem item) {
|
||||
String? art;
|
||||
final cover = item.coverPath;
|
||||
if (cover != null && cover.trim().isNotEmpty) {
|
||||
art = cover.startsWith('http') || cover.startsWith('content://')
|
||||
? cover
|
||||
: Uri.file(cover).toString();
|
||||
}
|
||||
return PlayableMedia(
|
||||
id: item.id,
|
||||
source: item.filePath,
|
||||
title: item.trackName,
|
||||
artist: item.artistName,
|
||||
album: item.albumName,
|
||||
artUri: art,
|
||||
duration: (item.duration != null && item.duration! > 0)
|
||||
? Duration(seconds: item.duration!)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -645,6 +645,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(saveDownloadHistory: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setPlayerMode(String mode) {
|
||||
final normalized = mode == 'internal' ? 'internal' : 'external';
|
||||
state = state.copyWith(playerMode: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -452,6 +452,73 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Playback'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.open_in_new,
|
||||
title: 'External player',
|
||||
subtitle:
|
||||
'Open tracks in another music app (recommended for best quality)',
|
||||
trailing: settings.playerMode == 'external'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () =>
|
||||
ref.read(settingsProvider.notifier).setPlayerMode('external'),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.play_circle_outline,
|
||||
title: 'Built-in player',
|
||||
subtitle:
|
||||
'Play inside SpotiFLAC with a notification and synced lyrics',
|
||||
trailing: settings.playerMode == 'internal'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () =>
|
||||
ref.read(settingsProvider.notifier).setPlayerMode('internal'),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The built-in player is intentionally simple (local files '
|
||||
'only, basic playback). For higher quality, gapless audio, '
|
||||
'equalizer and format support, a dedicated external player '
|
||||
'is more capable and recommended.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (settings.localLibraryEnabled) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.libraryActions),
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart'
|
||||
show AudioSession, AudioSessionConfiguration, AudioInterruptionType;
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('MusicPlayer');
|
||||
|
||||
class PlayableMedia {
|
||||
final String id;
|
||||
final String source;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String album;
|
||||
final String? artUri;
|
||||
final Duration? duration;
|
||||
|
||||
const PlayableMedia({
|
||||
required this.id,
|
||||
required this.source,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
this.album = '',
|
||||
this.artUri,
|
||||
this.duration,
|
||||
});
|
||||
|
||||
bool get isContentUri => source.startsWith('content://');
|
||||
|
||||
MediaItem toMediaItem({String? resolvedSource}) {
|
||||
return MediaItem(
|
||||
id: id,
|
||||
title: title.isEmpty ? 'Unknown title' : title,
|
||||
artist: artist.isEmpty ? 'Unknown artist' : artist,
|
||||
album: album.isEmpty ? null : album,
|
||||
duration: duration,
|
||||
artUri: (artUri != null && artUri!.isNotEmpty)
|
||||
? Uri.tryParse(artUri!)
|
||||
: null,
|
||||
extras: {
|
||||
'source': source,
|
||||
if (resolvedSource != null && resolvedSource.isNotEmpty)
|
||||
'resolvedSource': resolvedSource,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MusicPlayerHandler extends BaseAudioHandler
|
||||
with QueueHandler, SeekHandler {
|
||||
final AudioPlayer _player = AudioPlayer(playerId: 'music-player');
|
||||
AudioSession? _audioSession;
|
||||
final List<PlayableMedia> _media = [];
|
||||
final List<MediaItem> _queueItems = [];
|
||||
final Map<String, String> _resolvedPathCache = {};
|
||||
final List<String> _resolvedPathOrder = [];
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
int _index = -1;
|
||||
bool _initialized = false;
|
||||
|
||||
bool _shuffle = false;
|
||||
final Random _random = Random();
|
||||
final List<int> _recent = [];
|
||||
final List<int> _playHistory = [];
|
||||
|
||||
// True when playback was paused because another app took audio focus.
|
||||
bool _pausedByInterruption = false;
|
||||
bool _interruptionActive = false;
|
||||
bool _userPaused = false;
|
||||
bool _switchingTrack = false;
|
||||
DateTime _ignoreCompleteUntil = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
Duration _lastBroadcastPosition = Duration.zero;
|
||||
DateTime? _lastPositionBroadcastAt;
|
||||
static const Duration _positionBroadcastInterval = Duration(
|
||||
milliseconds: 500,
|
||||
);
|
||||
static const int _maxResolvedPathCacheEntries = 64;
|
||||
|
||||
MusicPlayerHandler() {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
_player.setReleaseMode(ReleaseMode.stop);
|
||||
unawaited(_configureAudioSession());
|
||||
|
||||
_subscriptions.addAll([
|
||||
_player.onPlayerStateChanged.listen((state) {
|
||||
_broadcastState(playerState: state);
|
||||
}),
|
||||
_player.onPositionChanged.listen(_broadcastPosition),
|
||||
_player.onDurationChanged.listen((duration) {
|
||||
final current = mediaItem.value;
|
||||
if (current != null && duration > Duration.zero) {
|
||||
mediaItem.add(current.copyWith(duration: duration));
|
||||
}
|
||||
}),
|
||||
_player.onPlayerComplete.listen((_) {
|
||||
unawaited(_handlePlayerComplete());
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Configures the OS audio session and reacts to interruptions (e.g. another
|
||||
/// app like PowerAmp taking audio focus, or headphones unplugged) so playback
|
||||
/// pauses and the UI/notification reflect the real state instead of staying
|
||||
/// stuck on "playing".
|
||||
Future<void> _configureAudioSession() async {
|
||||
try {
|
||||
final session = await AudioSession.instance;
|
||||
_audioSession = session;
|
||||
await session.configure(const AudioSessionConfiguration.music());
|
||||
|
||||
_subscriptions.add(
|
||||
session.interruptionEventStream.listen((event) {
|
||||
if (event.begin) {
|
||||
// Another app took focus or a transient interruption began.
|
||||
_interruptionActive = true;
|
||||
_pausedByInterruption =
|
||||
_player.state == PlayerState.playing ||
|
||||
playbackState.value.playing;
|
||||
_ignoreCompleteFor(const Duration(seconds: 3));
|
||||
unawaited(_pauseForFocusLoss());
|
||||
} else {
|
||||
// Focus returned; resume only if we paused due to a transient
|
||||
// (duck/pause) interruption.
|
||||
_interruptionActive = false;
|
||||
if (_pausedByInterruption &&
|
||||
event.type == AudioInterruptionType.pause) {
|
||||
_pausedByInterruption = false;
|
||||
unawaited(play());
|
||||
} else {
|
||||
_pausedByInterruption = false;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
_subscriptions.add(
|
||||
session.becomingNoisyEventStream.listen((_) {
|
||||
// Headphones unplugged / output route lost.
|
||||
_ignoreCompleteFor(const Duration(seconds: 3));
|
||||
unawaited(_pauseForFocusLoss());
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to configure audio session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _ignoreCompleteFor(Duration duration) {
|
||||
final until = DateTime.now().add(duration);
|
||||
if (until.isAfter(_ignoreCompleteUntil)) {
|
||||
_ignoreCompleteUntil = until;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _shouldIgnoreComplete =>
|
||||
_switchingTrack ||
|
||||
_interruptionActive ||
|
||||
_userPaused ||
|
||||
DateTime.now().isBefore(_ignoreCompleteUntil);
|
||||
|
||||
Future<void> _pauseForFocusLoss() async {
|
||||
try {
|
||||
await _player.pause();
|
||||
} catch (e) {
|
||||
_log.w('Failed to pause after audio focus loss: $e');
|
||||
}
|
||||
// Force the UI/notification to reflect the pause even if the engine does
|
||||
// not emit a state-change event on focus loss.
|
||||
_broadcastState(playerState: PlayerState.paused);
|
||||
}
|
||||
|
||||
Future<void> _activateAudioSession() async {
|
||||
try {
|
||||
await _audioSession?.setActive(true);
|
||||
} catch (e) {
|
||||
_log.w('Failed to activate audio session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
AudioProcessingState _mapProcessingState(PlayerState state) {
|
||||
switch (state) {
|
||||
case PlayerState.playing:
|
||||
case PlayerState.paused:
|
||||
return AudioProcessingState.ready;
|
||||
case PlayerState.completed:
|
||||
return AudioProcessingState.completed;
|
||||
case PlayerState.stopped:
|
||||
case PlayerState.disposed:
|
||||
return AudioProcessingState.idle;
|
||||
}
|
||||
}
|
||||
|
||||
void _broadcastState({PlayerState? playerState, bool? loading}) {
|
||||
final state = playerState ?? _player.state;
|
||||
final playing = state == PlayerState.playing;
|
||||
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: [
|
||||
MediaControl.skipToPrevious,
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
MediaAction.seekForward,
|
||||
MediaAction.seekBackward,
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.skipToNext,
|
||||
},
|
||||
androidCompactActionIndices: const [0, 1, 2],
|
||||
processingState: (loading == true)
|
||||
? AudioProcessingState.loading
|
||||
: _mapProcessingState(state),
|
||||
playing: playing,
|
||||
shuffleMode: _shuffle
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _broadcastPosition(Duration position, {bool force = false}) {
|
||||
final now = DateTime.now();
|
||||
final lastAt = _lastPositionBroadcastAt;
|
||||
final elapsed = lastAt == null ? null : now.difference(lastAt);
|
||||
final moved = (position - _lastBroadcastPosition).abs();
|
||||
if (!force &&
|
||||
elapsed != null &&
|
||||
elapsed < _positionBroadcastInterval &&
|
||||
moved < _positionBroadcastInterval) {
|
||||
return;
|
||||
}
|
||||
_lastPositionBroadcastAt = now;
|
||||
_lastBroadcastPosition = position;
|
||||
playbackState.add(playbackState.value.copyWith(updatePosition: position));
|
||||
}
|
||||
|
||||
Future<String?> _resolveSource(PlayableMedia media) async {
|
||||
if (!media.isContentUri) return media.source;
|
||||
|
||||
final cached = _resolvedPathCache[media.source];
|
||||
if (cached != null) return cached;
|
||||
try {
|
||||
final tempPath = await PlatformBridge.copyContentUriToTemp(media.source);
|
||||
if (tempPath != null && tempPath.isNotEmpty) {
|
||||
_resolvedPathCache[media.source] = tempPath;
|
||||
_resolvedPathOrder.remove(media.source);
|
||||
_resolvedPathOrder.add(media.source);
|
||||
while (_resolvedPathOrder.length > _maxResolvedPathCacheEntries) {
|
||||
final evicted = _resolvedPathOrder.removeAt(0);
|
||||
_resolvedPathCache.remove(evicted);
|
||||
}
|
||||
return tempPath;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to resolve content URI for playback: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> setQueueAndPlay(
|
||||
List<PlayableMedia> items, {
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
if (items.isEmpty) return;
|
||||
_media
|
||||
..clear()
|
||||
..addAll(items);
|
||||
_queueItems
|
||||
..clear()
|
||||
..addAll(items.map((m) => m.toMediaItem()));
|
||||
_recent.clear();
|
||||
_playHistory.clear();
|
||||
queue.add(List<MediaItem>.unmodifiable(_queueItems));
|
||||
await _playIndex(initialIndex.clamp(0, items.length - 1));
|
||||
}
|
||||
|
||||
Future<void> enqueue(PlayableMedia item, {bool playNext = false}) async {
|
||||
if (_media.isEmpty || _index < 0) {
|
||||
await setQueueAndPlay([item]);
|
||||
return;
|
||||
}
|
||||
final insertAt = playNext
|
||||
? (_index + 1).clamp(0, _media.length)
|
||||
: _media.length;
|
||||
_media.insert(insertAt, item);
|
||||
_queueItems.insert(insertAt, item.toMediaItem());
|
||||
|
||||
for (var i = 0; i < _recent.length; i++) {
|
||||
if (_recent[i] >= insertAt) _recent[i]++;
|
||||
}
|
||||
for (var i = 0; i < _playHistory.length; i++) {
|
||||
if (_playHistory[i] >= insertAt) _playHistory[i]++;
|
||||
}
|
||||
|
||||
queue.add(List<MediaItem>.unmodifiable(_queueItems));
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
Future<void> enqueueAll(
|
||||
List<PlayableMedia> items, {
|
||||
bool playNext = false,
|
||||
}) async {
|
||||
if (items.isEmpty) return;
|
||||
if (_media.isEmpty || _index < 0) {
|
||||
await setQueueAndPlay(items);
|
||||
return;
|
||||
}
|
||||
var at = playNext ? (_index + 1).clamp(0, _media.length) : _media.length;
|
||||
for (final item in items) {
|
||||
_media.insert(at, item);
|
||||
_queueItems.insert(at, item.toMediaItem());
|
||||
for (var i = 0; i < _recent.length; i++) {
|
||||
if (_recent[i] >= at) _recent[i]++;
|
||||
}
|
||||
for (var i = 0; i < _playHistory.length; i++) {
|
||||
if (_playHistory[i] >= at) _playHistory[i]++;
|
||||
}
|
||||
at++;
|
||||
}
|
||||
queue.add(List<MediaItem>.unmodifiable(_queueItems));
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
Future<void> _playIndex(int index, {bool recordHistory = true}) async {
|
||||
if (index < 0 || index >= _media.length) return;
|
||||
_index = index;
|
||||
_pausedByInterruption = false;
|
||||
_interruptionActive = false;
|
||||
_userPaused = false;
|
||||
|
||||
if (recordHistory) {
|
||||
_playHistory.add(index);
|
||||
if (_playHistory.length > 200) _playHistory.removeAt(0);
|
||||
_recent.add(index);
|
||||
final maxRecent = ((_media.length - 1) * 0.6).floor().clamp(
|
||||
1,
|
||||
_media.length > 1 ? _media.length - 1 : 1,
|
||||
);
|
||||
while (_recent.length > maxRecent) {
|
||||
_recent.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
final media = _media[index];
|
||||
mediaItem.add(media.toMediaItem());
|
||||
_lastBroadcastPosition = Duration.zero;
|
||||
_lastPositionBroadcastAt = null;
|
||||
// Claim the playing state up front (while the app is still in the
|
||||
// foreground window) so audio_service can start its foreground service
|
||||
// before the async source resolve below.
|
||||
_broadcastState(playerState: PlayerState.playing, loading: true);
|
||||
|
||||
final resolved = await _resolveSource(media);
|
||||
if (resolved == null) {
|
||||
_log.e('No playable source for ${media.title}');
|
||||
_broadcastState(playerState: PlayerState.stopped);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await musicPlayerExclusiveAudioHook?.call();
|
||||
} catch (_) {}
|
||||
|
||||
_switchingTrack = true;
|
||||
_ignoreCompleteFor(const Duration(seconds: 3));
|
||||
try {
|
||||
await _activateAudioSession();
|
||||
await _player.stop();
|
||||
await _player.play(DeviceFileSource(resolved));
|
||||
mediaItem.add(media.toMediaItem(resolvedSource: resolved));
|
||||
_broadcastPosition(Duration.zero, force: true);
|
||||
_broadcastState(playerState: PlayerState.playing);
|
||||
_log.i('Playing: ${media.title}');
|
||||
// Some files do not emit onDurationChanged reliably (stuck at 0:00);
|
||||
// poll the engine for the real duration as a fallback.
|
||||
unawaited(_ensureDurationKnown(index));
|
||||
} catch (e) {
|
||||
_log.e('Playback failed for ${media.title}: $e');
|
||||
_broadcastState(playerState: PlayerState.stopped);
|
||||
} finally {
|
||||
_switchingTrack = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the real track duration when the initial metadata had none and
|
||||
/// the duration-changed event did not fire, so the seek bar and total time
|
||||
/// do not get stuck at 0:00.
|
||||
Future<void> _ensureDurationKnown(int index) async {
|
||||
for (var attempt = 0; attempt < 15; attempt++) {
|
||||
if (_index != index) return; // track changed; stop polling
|
||||
final current = mediaItem.value;
|
||||
final existing = current?.duration;
|
||||
if (existing != null && existing > Duration.zero) return;
|
||||
|
||||
try {
|
||||
final d = await _player.getDuration();
|
||||
if (_index != index) return;
|
||||
if (d != null && d > Duration.zero) {
|
||||
final item = mediaItem.value;
|
||||
if (item != null) {
|
||||
mediaItem.add(item.copyWith(duration: d));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore and retry
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
}
|
||||
|
||||
int _pickNextShuffle() {
|
||||
if (_media.length <= 1) return _index;
|
||||
final pool = <int>[];
|
||||
for (var i = 0; i < _media.length; i++) {
|
||||
if (i != _index && !_recent.contains(i)) pool.add(i);
|
||||
}
|
||||
if (pool.isEmpty) {
|
||||
for (var i = 0; i < _media.length; i++) {
|
||||
if (i != _index) pool.add(i);
|
||||
}
|
||||
}
|
||||
return pool[_random.nextInt(pool.length)];
|
||||
}
|
||||
|
||||
Future<void> _onComplete() async {
|
||||
if (_shuffle) {
|
||||
if (_media.length > 1) {
|
||||
await _playIndex(_pickNextShuffle());
|
||||
} else {
|
||||
_broadcastState(playerState: PlayerState.completed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_index >= 0 && _index < _media.length - 1) {
|
||||
await _playIndex(_index + 1);
|
||||
} else {
|
||||
_broadcastState(playerState: PlayerState.completed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePlayerComplete() async {
|
||||
if (_shouldIgnoreComplete) {
|
||||
_log.d('Ignoring non-terminal player complete event');
|
||||
return;
|
||||
}
|
||||
|
||||
final duration = mediaItem.value?.duration ?? await _player.getDuration();
|
||||
final position =
|
||||
await _player.getCurrentPosition() ?? playbackState.value.position;
|
||||
if (duration != null &&
|
||||
duration > Duration.zero &&
|
||||
position < duration - const Duration(milliseconds: 1500)) {
|
||||
_log.d('Ignoring early player complete at $position / $duration');
|
||||
final state = _player.state;
|
||||
_broadcastState(
|
||||
playerState: state == PlayerState.playing
|
||||
? PlayerState.playing
|
||||
: PlayerState.paused,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _onComplete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
_pausedByInterruption = false;
|
||||
_interruptionActive = false;
|
||||
_userPaused = false;
|
||||
await _activateAudioSession();
|
||||
await _player.resume();
|
||||
_broadcastState(playerState: PlayerState.playing);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
_userPaused = true;
|
||||
_pausedByInterruption = false;
|
||||
_ignoreCompleteFor(const Duration(seconds: 3));
|
||||
await _player.pause();
|
||||
_broadcastState(playerState: PlayerState.paused);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
await _player.seek(position);
|
||||
_broadcastPosition(position, force: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||
_shuffle = shuffleMode == AudioServiceShuffleMode.all;
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_ignoreCompleteFor(const Duration(seconds: 3));
|
||||
await _player.stop();
|
||||
_index = -1;
|
||||
_pausedByInterruption = false;
|
||||
_interruptionActive = false;
|
||||
_userPaused = false;
|
||||
_recent.clear();
|
||||
_playHistory.clear();
|
||||
_broadcastState(playerState: PlayerState.stopped);
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
if (_shuffle) {
|
||||
if (_media.length > 1) await _playIndex(_pickNextShuffle());
|
||||
return;
|
||||
}
|
||||
if (_index < _media.length - 1) await _playIndex(_index + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToPrevious() async {
|
||||
if (playbackState.value.position > const Duration(seconds: 3)) {
|
||||
await _player.seek(Duration.zero);
|
||||
_broadcastPosition(Duration.zero, force: true);
|
||||
return;
|
||||
}
|
||||
if (_shuffle) {
|
||||
if (_playHistory.length >= 2) {
|
||||
_playHistory.removeLast();
|
||||
final prev = _playHistory.last;
|
||||
await _playIndex(prev, recordHistory: false);
|
||||
} else {
|
||||
await _player.seek(Duration.zero);
|
||||
_broadcastPosition(Duration.zero, force: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_index > 0) await _playIndex(_index - 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToQueueItem(int index) => _playIndex(index);
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> getChildren(
|
||||
String parentMediaId, [
|
||||
Map<String, dynamic>? options,
|
||||
]) async {
|
||||
if (parentMediaId == AudioService.browsableRootId ||
|
||||
parentMediaId == AudioService.recentRootId) {
|
||||
return List<MediaItem>.unmodifiable(_queueItems);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MediaItem?> getMediaItem(String mediaId) async {
|
||||
final index = _media.indexWhere((m) => m.id == mediaId);
|
||||
if (index < 0) return null;
|
||||
return _queueItems[index];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playFromMediaId(
|
||||
String mediaId, [
|
||||
Map<String, dynamic>? extras,
|
||||
]) async {
|
||||
final index = _media.indexWhere((m) => m.id == mediaId);
|
||||
if (index >= 0) await _playIndex(index);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playMediaItem(MediaItem mediaItem) =>
|
||||
playFromMediaId(mediaItem.id);
|
||||
|
||||
/// Called when a file is deleted from disk. Removes it from the queue and, if
|
||||
/// it is the track currently playing, stops or advances so a deleted song can
|
||||
/// no longer be played.
|
||||
Future<void> onSourceDeleted(String source) async {
|
||||
final target = source.trim();
|
||||
if (target.isEmpty || _media.isEmpty) return;
|
||||
|
||||
_resolvedPathCache.remove(target);
|
||||
_resolvedPathOrder.remove(target);
|
||||
|
||||
final wasCurrent =
|
||||
_index >= 0 &&
|
||||
_index < _media.length &&
|
||||
_media[_index].source == target;
|
||||
|
||||
var removedBeforeCurrent = 0;
|
||||
final kept = <PlayableMedia>[];
|
||||
for (var i = 0; i < _media.length; i++) {
|
||||
if (_media[i].source == target) {
|
||||
if (i < _index) removedBeforeCurrent++;
|
||||
continue;
|
||||
}
|
||||
kept.add(_media[i]);
|
||||
}
|
||||
|
||||
if (kept.length == _media.length) return; // nothing matched
|
||||
|
||||
_media
|
||||
..clear()
|
||||
..addAll(kept);
|
||||
_queueItems
|
||||
..clear()
|
||||
..addAll(kept.map((m) => m.toMediaItem()));
|
||||
_recent.clear();
|
||||
_playHistory.clear();
|
||||
queue.add(List<MediaItem>.unmodifiable(_queueItems));
|
||||
|
||||
if (_media.isEmpty) {
|
||||
await stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasCurrent) {
|
||||
final nextIndex = _index.clamp(0, _media.length - 1);
|
||||
await _playIndex(nextIndex);
|
||||
} else {
|
||||
_index = (_index - removedBeforeCurrent).clamp(0, _media.length - 1);
|
||||
_broadcastState();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
for (final sub in _subscriptions) {
|
||||
await sub.cancel();
|
||||
}
|
||||
_subscriptions.clear();
|
||||
await _player.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
MusicPlayerHandler? _handler;
|
||||
Future<MusicPlayerHandler>? _initFuture;
|
||||
final StreamController<MusicPlayerHandler> _handlerReadyController =
|
||||
StreamController<MusicPlayerHandler>.broadcast();
|
||||
|
||||
MusicPlayerHandler? get musicPlayerHandler => _handler;
|
||||
|
||||
Future<void> Function()? musicPlayerExclusiveAudioHook;
|
||||
|
||||
Future<MusicPlayerHandler> initMusicPlayer() async {
|
||||
if (_handler != null) return _handler!;
|
||||
final existingFuture = _initFuture;
|
||||
if (existingFuture != null) return existingFuture;
|
||||
|
||||
final future = _doInitMusicPlayer();
|
||||
_initFuture = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<MusicPlayerHandler> _doInitMusicPlayer() async {
|
||||
try {
|
||||
final handler = await AudioService.init(
|
||||
builder: () => MusicPlayerHandler(),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'com.zarz.spotiflac.playback',
|
||||
androidNotificationChannelName: 'Playback',
|
||||
androidNotificationOngoing: true,
|
||||
androidStopForegroundOnPause: true,
|
||||
),
|
||||
);
|
||||
_handler = handler;
|
||||
_handlerReadyController.add(handler);
|
||||
return handler;
|
||||
} catch (_) {
|
||||
_initFuture = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<MediaItem?> musicPlayerMediaItemEvents() async* {
|
||||
final existing = _handler;
|
||||
if (existing != null) {
|
||||
yield existing.mediaItem.value;
|
||||
yield* existing.mediaItem;
|
||||
return;
|
||||
}
|
||||
yield null;
|
||||
await for (final handler in _handlerReadyController.stream) {
|
||||
yield handler.mediaItem.value;
|
||||
yield* handler.mediaItem;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<PlaybackState> musicPlayerPlaybackStateEvents() async* {
|
||||
final existing = _handler;
|
||||
if (existing != null) {
|
||||
yield existing.playbackState.value;
|
||||
yield* existing.playbackState;
|
||||
return;
|
||||
}
|
||||
await for (final handler in _handlerReadyController.stream) {
|
||||
yield handler.playbackState.value;
|
||||
yield* handler.playbackState;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<MediaItem>> musicPlayerQueueEvents() async* {
|
||||
final existing = _handler;
|
||||
if (existing != null) {
|
||||
yield existing.queue.value;
|
||||
yield* existing.queue;
|
||||
return;
|
||||
}
|
||||
yield const [];
|
||||
await for (final handler in _handlerReadyController.stream) {
|
||||
yield handler.queue.value;
|
||||
yield* handler.queue;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
class LyricWord {
|
||||
final Duration time;
|
||||
final String text;
|
||||
|
||||
const LyricWord({required this.time, required this.text});
|
||||
}
|
||||
|
||||
class LyricLine {
|
||||
final Duration time;
|
||||
final Duration? end;
|
||||
final String text;
|
||||
final List<LyricWord> words;
|
||||
|
||||
const LyricLine({
|
||||
required this.time,
|
||||
this.end,
|
||||
required this.text,
|
||||
this.words = const [],
|
||||
});
|
||||
|
||||
bool get hasWordTiming => words.isNotEmpty;
|
||||
}
|
||||
|
||||
class ParsedLyrics {
|
||||
final bool synced;
|
||||
final bool wordSynced;
|
||||
final List<LyricLine> lines;
|
||||
final String plainText;
|
||||
|
||||
const ParsedLyrics({
|
||||
required this.synced,
|
||||
required this.wordSynced,
|
||||
required this.lines,
|
||||
required this.plainText,
|
||||
});
|
||||
|
||||
bool get isEmpty => lines.isEmpty && plainText.trim().isEmpty;
|
||||
|
||||
static const ParsedLyrics empty = ParsedLyrics(
|
||||
synced: false,
|
||||
wordSynced: false,
|
||||
lines: [],
|
||||
plainText: '',
|
||||
);
|
||||
}
|
||||
|
||||
class LyricsParser {
|
||||
LyricsParser._();
|
||||
|
||||
// [mm:ss.xx] or [mm:ss.xxx] or [mm:ss]
|
||||
static final RegExp _lineTimeTag = RegExp(
|
||||
r'\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]',
|
||||
);
|
||||
|
||||
// <mm:ss.xx> inline word timestamp (enhanced LRC).
|
||||
static final RegExp _wordTimeTag = RegExp(
|
||||
r'<(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?>',
|
||||
);
|
||||
|
||||
// ID tags such as [ti:..], [ar:..], [offset:..].
|
||||
static final RegExp _idTag = RegExp(
|
||||
r'^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder):.*\]$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static ParsedLyrics parse(String? raw) {
|
||||
final text = (raw ?? '').trim();
|
||||
if (text.isEmpty) return ParsedLyrics.empty;
|
||||
|
||||
if (_looksLikeTtml(text)) {
|
||||
final ttml = _parseTtml(text);
|
||||
if (ttml != null && ttml.lines.isNotEmpty) return ttml;
|
||||
}
|
||||
|
||||
return _parseLrcOrPlain(text);
|
||||
}
|
||||
|
||||
static bool _looksLikeTtml(String text) {
|
||||
final head = text.trimLeft();
|
||||
return head.startsWith('<?xml') ||
|
||||
head.startsWith('<tt') ||
|
||||
head.contains('<tt ') ||
|
||||
head.contains('http://www.w3.org/ns/ttml');
|
||||
}
|
||||
|
||||
static Duration? _toDuration(String? min, String? sec, String? frac) {
|
||||
if (min == null || sec == null) return null;
|
||||
final m = int.tryParse(min) ?? 0;
|
||||
final s = int.tryParse(sec) ?? 0;
|
||||
var ms = 0;
|
||||
if (frac != null && frac.isNotEmpty) {
|
||||
// Normalize to milliseconds regardless of 2 or 3 digit fractions.
|
||||
final padded = frac.padRight(3, '0').substring(0, 3);
|
||||
ms = int.tryParse(padded) ?? 0;
|
||||
}
|
||||
return Duration(minutes: m, seconds: s, milliseconds: ms);
|
||||
}
|
||||
|
||||
static ParsedLyrics _parseLrcOrPlain(String text) {
|
||||
final rawLines = text.split(RegExp(r'\r\n|\r|\n'));
|
||||
final parsed = <LyricLine>[];
|
||||
final plainBuffer = <String>[];
|
||||
var sawTimestamp = false;
|
||||
var sawWordTiming = false;
|
||||
var offsetMs = 0;
|
||||
|
||||
for (final rawLine in rawLines) {
|
||||
final line = rawLine.trimRight();
|
||||
if (line.trim().isEmpty) continue;
|
||||
|
||||
// Capture [offset:] for timing correction, drop other ID tags.
|
||||
final idMatch = _idTag.firstMatch(line.trim());
|
||||
if (idMatch != null) {
|
||||
final key = idMatch.group(1)!.toLowerCase();
|
||||
if (key == 'offset') {
|
||||
final value = line.substring(line.indexOf(':') + 1).replaceAll(']', '').trim();
|
||||
offsetMs = int.tryParse(value) ?? 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final timeMatches = _lineTimeTag.allMatches(line).toList();
|
||||
if (timeMatches.isEmpty) {
|
||||
// No timestamp: treat as plain text line.
|
||||
plainBuffer.add(line.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
sawTimestamp = true;
|
||||
|
||||
// Strip leading line timestamps to obtain the lyric content.
|
||||
final lastTag = timeMatches.last;
|
||||
final content = line.substring(lastTag.end).trim();
|
||||
|
||||
// Enhanced LRC word timestamps inside the content.
|
||||
final words = _parseWords(content);
|
||||
if (words.isNotEmpty) sawWordTiming = true;
|
||||
final cleanContent = content.replaceAll(_wordTimeTag, '').trim();
|
||||
plainBuffer.add(cleanContent);
|
||||
|
||||
// A line can have multiple timestamps (repeated chorus).
|
||||
for (final tm in timeMatches) {
|
||||
final d = _toDuration(tm.group(1), tm.group(2), tm.group(3));
|
||||
if (d == null) continue;
|
||||
parsed.add(
|
||||
LyricLine(
|
||||
time: d,
|
||||
text: cleanContent,
|
||||
words: words,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawTimestamp) {
|
||||
// Pure plain text.
|
||||
return ParsedLyrics(
|
||||
synced: false,
|
||||
wordSynced: false,
|
||||
lines: const [],
|
||||
plainText: rawLines
|
||||
.map((l) => l.trim())
|
||||
.where((l) => l.isNotEmpty && _idTag.firstMatch(l) == null)
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
parsed.sort((a, b) => a.time.compareTo(b.time));
|
||||
|
||||
final adjusted = offsetMs == 0
|
||||
? parsed
|
||||
: parsed
|
||||
.map(
|
||||
(l) => LyricLine(
|
||||
time: _shift(l.time, offsetMs),
|
||||
end: l.end,
|
||||
text: l.text,
|
||||
words: l.words
|
||||
.map(
|
||||
(w) =>
|
||||
LyricWord(time: _shift(w.time, offsetMs), text: w.text),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return ParsedLyrics(
|
||||
synced: true,
|
||||
wordSynced: sawWordTiming,
|
||||
lines: adjusted,
|
||||
plainText: plainBuffer.where((l) => l.isNotEmpty).join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
static Duration _shift(Duration d, int offsetMs) {
|
||||
// LRC offset: positive value shifts lyrics earlier.
|
||||
final ms = d.inMilliseconds - offsetMs;
|
||||
return Duration(milliseconds: ms < 0 ? 0 : ms);
|
||||
}
|
||||
|
||||
static List<LyricWord> _parseWords(String content) {
|
||||
final matches = _wordTimeTag.allMatches(content).toList();
|
||||
if (matches.isEmpty) return const [];
|
||||
|
||||
final words = <LyricWord>[];
|
||||
for (var i = 0; i < matches.length; i++) {
|
||||
final m = matches[i];
|
||||
final d = _toDuration(m.group(1), m.group(2), m.group(3));
|
||||
if (d == null) continue;
|
||||
final start = m.end;
|
||||
final end = i + 1 < matches.length ? matches[i + 1].start : content.length;
|
||||
final word = content.substring(start, end);
|
||||
if (word.trim().isEmpty) continue;
|
||||
words.add(LyricWord(time: d, text: word));
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
static ParsedLyrics? _parseTtml(String text) {
|
||||
try {
|
||||
final doc = XmlDocument.parse(text);
|
||||
final paragraphs = doc.findAllElements('p').toList();
|
||||
if (paragraphs.isEmpty) return null;
|
||||
|
||||
final lines = <LyricLine>[];
|
||||
final plain = <String>[];
|
||||
var sawWords = false;
|
||||
|
||||
for (final p in paragraphs) {
|
||||
final begin = _parseClock(p.getAttribute('begin'));
|
||||
final end = _parseClock(p.getAttribute('end'));
|
||||
|
||||
// Word/syllable spans carry their own begin attribute.
|
||||
final spans = p.findElements('span').toList();
|
||||
final words = <LyricWord>[];
|
||||
if (spans.isNotEmpty) {
|
||||
for (final span in spans) {
|
||||
final sBegin = _parseClock(span.getAttribute('begin'));
|
||||
final spanText = span.innerText;
|
||||
if (sBegin != null && spanText.trim().isNotEmpty) {
|
||||
words.add(LyricWord(time: sBegin, text: '$spanText '));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (words.isNotEmpty) sawWords = true;
|
||||
|
||||
final lineText = p.innerText.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
if (lineText.isEmpty && words.isEmpty) continue;
|
||||
plain.add(lineText);
|
||||
|
||||
if (begin != null) {
|
||||
lines.add(
|
||||
LyricLine(
|
||||
time: begin,
|
||||
end: end,
|
||||
text: lineText,
|
||||
words: words,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.isEmpty) {
|
||||
return ParsedLyrics(
|
||||
synced: false,
|
||||
wordSynced: false,
|
||||
lines: const [],
|
||||
plainText: plain.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
lines.sort((a, b) => a.time.compareTo(b.time));
|
||||
return ParsedLyrics(
|
||||
synced: true,
|
||||
wordSynced: sawWords,
|
||||
lines: lines,
|
||||
plainText: plain.where((l) => l.isNotEmpty).join('\n'),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// TTML clock value: "mm:ss.fff", "hh:mm:ss.fff" or "12.5s".
|
||||
static Duration? _parseClock(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
final v = value.trim();
|
||||
|
||||
if (v.endsWith('s') && !v.contains(':')) {
|
||||
final seconds = double.tryParse(v.substring(0, v.length - 1));
|
||||
if (seconds == null) return null;
|
||||
return Duration(milliseconds: (seconds * 1000).round());
|
||||
}
|
||||
|
||||
final parts = v.split(':');
|
||||
try {
|
||||
if (parts.length == 3) {
|
||||
final h = int.parse(parts[0]);
|
||||
final m = int.parse(parts[1]);
|
||||
final s = double.parse(parts[2]);
|
||||
return Duration(
|
||||
hours: h,
|
||||
minutes: m,
|
||||
milliseconds: (s * 1000).round(),
|
||||
);
|
||||
} else if (parts.length == 2) {
|
||||
final m = int.parse(parts[0]);
|
||||
final s = double.parse(parts[1]);
|
||||
return Duration(minutes: m, milliseconds: (s * 1000).round());
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int activeIndex(List<LyricLine> lines, Duration position) {
|
||||
if (lines.isEmpty) return -1;
|
||||
var lo = 0;
|
||||
var hi = lines.length - 1;
|
||||
var result = -1;
|
||||
while (lo <= hi) {
|
||||
final mid = (lo + hi) >> 1;
|
||||
if (lines[mid].time <= position) {
|
||||
result = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/screens/now_playing_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class MiniPlayer extends ConsumerWidget {
|
||||
const MiniPlayer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mediaItem = ref.watch(currentMediaItemProvider).value;
|
||||
if (mediaItem == null) return const SizedBox.shrink();
|
||||
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
final isPlaying = playback?.playing ?? false;
|
||||
final controller = ref.read(musicPlayerControllerProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final duration = mediaItem.duration?.inMilliseconds ?? 0;
|
||||
final position = playback?.position.inMilliseconds ?? 0;
|
||||
final progress = duration > 0 ? (position / duration).clamp(0.0, 1.0) : 0.0;
|
||||
|
||||
return DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: settingsGroupColor(context).withValues(alpha: 0.72),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const NowPlayingScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 2,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: _MiniArt(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
mediaItem.title,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
mediaItem.artist ?? '',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: () => controller.togglePlayPause(isPlaying),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: controller.next,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniArt extends StatelessWidget {
|
||||
final String? artUri;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _MiniArt({required this.artUri, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeholder = Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 22,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
final uri = artUri;
|
||||
if (uri == null || uri.isEmpty) return placeholder;
|
||||
if (uri.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
memCacheWidth: 132,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeOutDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
if (uri.startsWith('file://')) {
|
||||
return Image.file(
|
||||
File(Uri.parse(uri).toFilePath()),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 132,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
+47
-7
@@ -65,6 +65,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
audio_service:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audio_service
|
||||
sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.18"
|
||||
audio_service_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_service_platform_interface
|
||||
sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
audio_service_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_service_web
|
||||
sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
audio_session:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "7217b229db57cc4dc577a8abb56b7429a5a212b978517a5be578704bfe5e568b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.3"
|
||||
audioplayers:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -333,10 +365,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.14"
|
||||
version: "0.7.13"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -657,10 +689,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
version: "4.9.1"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -693,6 +725,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1547,13 +1587,13 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
version: "7.0.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -61,9 +61,13 @@ dependencies:
|
||||
|
||||
video_player: ^2.8.0
|
||||
|
||||
xml: ^7.0.1
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: ^22.0.1
|
||||
audioplayers: ^6.8.1
|
||||
audio_service: ^0.18.18
|
||||
audio_session: ^0.2.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user