Files
SpotiFLAC-Mobile/lib/providers/preview_player_provider.dart
T
zarzet 12fb942f16 feat: playback queue, preview exclusivity, player bug fixes
- PlaybackController with queue methods for albums/library/playlists
- Library tab play builds merged queue (downloaded + local together)
- Preview vs main player exclusivity
- Preview stops on bottom-nav tab switch
- Duration 0:00 fix, deleted track cleanup, Up Next sheet
- Animation utilities improvements
2026-06-28 22:22:51 +07:00

249 lines
6.3 KiB
Dart

import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PreviewPlayer');
enum PreviewStatus { idle, loading, playing, paused }
class PreviewPlayerState {
final String? activeUrl;
final PreviewStatus status;
final Duration position;
final Duration duration;
const PreviewPlayerState({
this.activeUrl,
this.status = PreviewStatus.idle,
this.position = Duration.zero,
this.duration = Duration.zero,
});
bool get isActive => activeUrl != null && activeUrl!.isNotEmpty;
bool isActiveUrl(String? url) =>
url != null && url.isNotEmpty && url == activeUrl;
double get progress {
final total = duration.inMilliseconds;
if (total <= 0) return 0;
return (position.inMilliseconds / total).clamp(0.0, 1.0);
}
PreviewPlayerState copyWith({
String? activeUrl,
bool clearActiveUrl = false,
PreviewStatus? status,
Duration? position,
Duration? duration,
}) {
return PreviewPlayerState(
activeUrl: clearActiveUrl ? null : (activeUrl ?? this.activeUrl),
status: status ?? this.status,
position: position ?? this.position,
duration: duration ?? this.duration,
);
}
}
class PreviewPlayerController extends Notifier<PreviewPlayerState> {
AudioPlayer? _player;
final List<StreamSubscription<dynamic>> _subscriptions = [];
AppLifecycleListener? _lifecycleListener;
@override
PreviewPlayerState build() {
_lifecycleListener = AppLifecycleListener(
onStateChange: _handleAppLifecycleState,
);
musicPlayerExclusiveAudioHook = () async {
if (state.isActive) await stop();
};
ref.onDispose(() {
musicPlayerExclusiveAudioHook = null;
_disposePlayer();
});
return const PreviewPlayerState();
}
void _handleAppLifecycleState(AppLifecycleState lifecycleState) {
if (lifecycleState == AppLifecycleState.paused ||
lifecycleState == AppLifecycleState.hidden ||
lifecycleState == AppLifecycleState.detached) {
if (state.isActive) {
unawaited(stop());
}
}
}
AudioPlayer _ensurePlayer() {
final existing = _player;
if (existing != null) return existing;
final player = AudioPlayer(playerId: 'preview-player');
player.setReleaseMode(ReleaseMode.stop);
_attachListeners(player);
_player = player;
return player;
}
void _attachListeners(AudioPlayer player) {
_subscriptions.add(
player.onPlayerStateChanged.listen(_handlePlayerStateChanged),
);
_subscriptions.add(
player.onPositionChanged.listen((position) {
if (state.status == PreviewStatus.playing ||
state.status == PreviewStatus.paused) {
state = state.copyWith(position: position);
}
}),
);
_subscriptions.add(
player.onDurationChanged.listen((duration) {
state = state.copyWith(duration: duration);
}),
);
_subscriptions.add(
player.onPlayerComplete.listen((_) {
_log.d('Preview playback completed');
state = const PreviewPlayerState();
}),
);
}
void _discardActivePlayer() {
for (final sub in _subscriptions) {
sub.cancel();
}
_subscriptions.clear();
final player = _player;
_player = null;
if (player != null) {
try {
player.dispose();
} catch (_) {}
}
}
void _handlePlayerStateChanged(PlayerState playerState) {
switch (playerState) {
case PlayerState.playing:
state = state.copyWith(status: PreviewStatus.playing);
break;
case PlayerState.paused:
if (state.isActive) {
state = state.copyWith(status: PreviewStatus.paused);
}
break;
case PlayerState.stopped:
case PlayerState.completed:
break;
case PlayerState.disposed:
break;
}
}
Future<void> toggle(String? url) async {
final trimmed = url?.trim() ?? '';
if (trimmed.isEmpty) return;
if (state.isActiveUrl(trimmed)) {
if (state.status == PreviewStatus.playing) {
await pause();
} else if (state.status == PreviewStatus.paused) {
await resume();
}
return;
}
await play(trimmed);
}
Future<void> play(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) return;
try {
await musicPlayerHandler?.pause();
} catch (_) {}
state = PreviewPlayerState(
activeUrl: trimmed,
status: PreviewStatus.loading,
);
try {
_log.i('Starting preview playback');
await _playOnPlayer(_ensurePlayer(), trimmed);
} catch (e) {
_log.w('Preview playback failed, recreating player and retrying: $e');
_discardActivePlayer();
try {
await _playOnPlayer(_ensurePlayer(), trimmed);
} catch (retryError) {
_log.e('Preview playback failed after retry', retryError);
_discardActivePlayer();
state = const PreviewPlayerState();
rethrow;
}
}
}
Future<void> _playOnPlayer(AudioPlayer player, String url) async {
await player.stop();
await player.play(UrlSource(url));
}
Future<void> pause() async {
final player = _player;
if (player == null) return;
try {
await player.pause();
state = state.copyWith(status: PreviewStatus.paused);
} catch (e) {
_log.w('Failed to pause preview: $e');
}
}
Future<void> resume() async {
final player = _player;
if (player == null || !state.isActive) return;
try {
await player.resume();
state = state.copyWith(status: PreviewStatus.playing);
} catch (e) {
_log.w('Failed to resume preview: $e');
}
}
Future<void> stop() async {
final player = _player;
if (player == null) {
state = const PreviewPlayerState();
return;
}
try {
await player.stop();
} catch (e) {
_log.w('Failed to stop preview: $e');
}
state = const PreviewPlayerState();
}
void _disposePlayer() {
_lifecycleListener?.dispose();
_lifecycleListener = null;
_discardActivePlayer();
}
}
final previewPlayerProvider =
NotifierProvider<PreviewPlayerController, PreviewPlayerState>(
PreviewPlayerController.new,
);