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 { AudioPlayer? _player; final List> _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 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 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 _playOnPlayer(AudioPlayer player, String url) async { await player.stop(); await player.play(UrlSource(url)); } Future 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 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 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.new, );