import 'dart:typed_data'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart' as media_kit; import 'package:groovybox/data/db.dart' as db; import 'package:groovybox/logic/metadata_service.dart'; import 'package:groovybox/providers/audio_provider.dart'; import 'package:groovybox/providers/theme_provider.dart'; import 'package:groovybox/providers/remote_provider.dart'; import 'package:groovybox/providers/db_provider.dart'; import 'package:groovybox/providers/settings_provider.dart'; class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final media_kit.Player _player; List _queue = []; int _queueIndex = 0; ProviderContainer? _container; AudioHandler() : _player = media_kit.Player() { // Configure for audio // _player.setPlaylistMode(PlaylistMode.loop); // Optional // Listen to player state changes and broadcast to audio_service _player.stream.playing.listen((playing) { _broadcastPlaybackState(); }); _player.stream.position.listen((position) { _broadcastPlaybackState(); }); _player.stream.duration.listen((duration) { _broadcastPlaybackState(); }); _player.stream.playlist.listen((playlist) { if (playlist.medias.isNotEmpty) { final currentIndex = playlist.index; if (currentIndex >= 0 && currentIndex < _queue.length) { _queueIndex = currentIndex; final currentItem = _queue[_queueIndex]; mediaItem.add(currentItem); _updateThemeFromCurrentTrack(currentItem); } } }); _player.stream.completed.listen((completed) async { if (completed && _container != null) { final continuePlays = _container! .read(settingsProvider) .when( data: (settings) => settings.continuePlays, loading: () => false, error: (_, __) => false, ); if (continuePlays && _queueIndex == _queue.length - 1) { final oldLength = _queue.length; await _addRandomTracksToQueue(); _queueIndex = oldLength; // Point to first new track await _updatePlaylist(); _broadcastPlaybackState(); } } }); } // Method to set the provider container for theme updates void setProviderContainer(ProviderContainer container) { _container = container; } // Update theme color based on current track's album art and set current metadata and track void _updateThemeFromCurrentTrack(MediaItem mediaItem) async { if (_container == null) return; try { TrackMetadata? metadata; db.Track? track; // Set loading state for remote tracks final urlResolver = _container!.read(remoteUrlResolverProvider); final isRemoteTrack = urlResolver.isProtocolUrl(mediaItem.id); final loadingNotifier = _container!.read( remoteTrackLoadingProvider.notifier, ); loadingNotifier.setLoading(true); // For remote tracks, get metadata from database if (isRemoteTrack) { final database = _container!.read(databaseProvider); track = await (database.select( database.tracks, )..where((t) => t.path.equals(mediaItem.id))).getSingleOrNull(); if (track != null) { // Fetch album art bytes for remote tracks Uint8List? artBytes; if (track.artUri != null) { final imageFile = await DefaultCacheManager().getSingleFile( track.artUri!, ); artBytes = await imageFile.readAsBytes(); } metadata = TrackMetadata( title: track.title, artist: track.artist, album: track.album, artBytes: artBytes, ); // Update theme from album art final seedColorNotifier = _container!.read( seedColorProvider.notifier, ); seedColorNotifier.updateFromAlbumArtBytes(artBytes); } } else { // For local tracks, get from database and use metadata service final database = _container!.read(databaseProvider); track = await (database.select( database.tracks, )..where((t) => t.path.equals(mediaItem.id))).getSingleOrNull(); // Use metadata service for local tracks final metadataService = MetadataService(); metadata = await metadataService.getMetadata(mediaItem.id); // Update theme from album art final seedColorNotifier = _container!.read(seedColorProvider.notifier); seedColorNotifier.updateFromAlbumArtBytes(metadata.artBytes); } // Clear loading state loadingNotifier.setLoading(false); // Set current track final trackNotifier = _container!.read(currentTrackProvider.notifier); if (track != null) { trackNotifier.setTrack(CurrentTrackData.fromTrack(track)); } else { trackNotifier.clear(); } // Set current track metadata final metadataNotifier = _container!.read( currentTrackMetadataProvider.notifier, ); if (metadata != null) { metadataNotifier.setMetadata(metadata); } else { metadataNotifier.clear(); } } catch (e) { // Clear loading state on error final loadingNotifier = _container!.read( remoteTrackLoadingProvider.notifier, ); loadingNotifier.setLoading(false); // If metadata retrieval fails, reset to default color and clear metadata/track final seedColorNotifier = _container!.read(seedColorProvider.notifier); seedColorNotifier.resetToDefault(); final trackNotifier = _container!.read(currentTrackProvider.notifier); trackNotifier.clear(); final metadataNotifier = _container!.read( currentTrackMetadataProvider.notifier, ); metadataNotifier.clear(); } } media_kit.Player get player => _player; // AudioService callbacks @override Future play() => _player.play(); @override Future pause() => _player.pause(); @override Future stop() => _player.stop(); @override Future seek(Duration position) => _player.seek(position); @override Future skipToNext() async { if (_queueIndex < _queue.length - 1) { _queueIndex++; await _player.jump(_queueIndex); } } @override Future skipToPrevious() async { if (_queueIndex > 0) { _queueIndex--; await _player.jump(_queueIndex); } } @override Future skipToQueueItem(int index) async { if (index >= 0 && index < _queue.length) { _queueIndex = index; await _player.jump(index); } } @override Future addQueueItem(MediaItem mediaItem) async { _queue.add(mediaItem); queue.add(_queue); await _updatePlaylist(); } @override Future insertQueueItem(int index, MediaItem mediaItem) async { if (index >= 0 && index <= _queue.length) { _queue.insert(index, mediaItem); queue.add(_queue); await _updatePlaylist(); } } @override Future removeQueueItem(MediaItem mediaItem) async { _queue.remove(mediaItem); queue.add(_queue); await _updatePlaylist(); } @override Future updateQueue(List queue) async { _queue = List.from(queue); this.queue.add(_queue); await _updatePlaylist(); } Future _updatePlaylist() async { if (_container == null) { // Fallback if container not set final medias = _queue.map((item) => media_kit.Media(item.id)).toList(); if (medias.isNotEmpty) { await _player.open(media_kit.Playlist(medias, index: _queueIndex)); } return; } final urlResolver = _container!.read(remoteUrlResolverProvider); final medias = []; for (final item in _queue) { String uri = item.id; // Check if this is a protocol URL that needs resolution if (urlResolver.isProtocolUrl(item.id)) { final resolvedUrl = await urlResolver.resolveUrl(item.id); if (resolvedUrl != null) { uri = resolvedUrl; } else { // If resolution fails, skip this track or use original URL continue; } } // Store the original track path in extras for queue lookup medias.add(media_kit.Media(uri, extras: {'trackPath': item.id})); } if (medias.isNotEmpty) { await _player.open(media_kit.Playlist(medias, index: _queueIndex)); } } void _broadcastPlaybackState() { final playing = _player.state.playing; final position = _player.state.position; final duration = _player.state.duration; // Get current media item metadata if available MediaItem? currentMediaItem; if (_queueIndex >= 0 && _queueIndex < _queue.length) { currentMediaItem = _queue[_queueIndex]; } playbackState.add( PlaybackState( controls: [ MediaControl.skipToPrevious, playing ? MediaControl.pause : MediaControl.play, MediaControl.stop, MediaControl.skipToNext, ], systemActions: const { MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward, }, androidCompactActionIndices: const [0, 1, 3], processingState: AudioProcessingState.ready, playing: playing, updatePosition: position, bufferedPosition: duration, speed: 1.0, queueIndex: _queueIndex, ), ); // Update media item separately if we have current track info if (currentMediaItem != null) { mediaItem.add(currentMediaItem); } } // New methods that accept Track objects with proper metadata Future playTrack(db.Track track) async { final mediaItem = await _trackToMediaItem(track); await updateQueue([mediaItem]); } Future playTracks(List tracks, {int initialIndex = 0}) async { final mediaItems = await Future.wait(tracks.map(_trackToMediaItem)); _queueIndex = initialIndex; await updateQueue(mediaItems); } Future _trackToMediaItem(db.Track track) async { Uri? artUri; if (track.artUri != null) { // Check if it's a network URL or local file path if (track.artUri!.startsWith('http://') || track.artUri!.startsWith('https://')) { // It's a network URL, cache it and get local file path try { final cachedFile = await DefaultCacheManager().getSingleFile( track.artUri!, ); artUri = Uri.file(cachedFile.path); } catch (e) { // If caching fails, try to use the network URL directly artUri = Uri.parse(track.artUri!); } } else { // It's a local file path artUri = Uri.file(track.artUri!); } } return MediaItem( id: track.path, album: track.album, title: track.title, artist: track.artist, duration: track.duration != null ? Duration(milliseconds: track.duration!) : null, artUri: artUri, ); } Future openPlaylist( List medias, { int initialIndex = 0, }) async { final mediaItems = medias.map((media) { return MediaItem( id: media.uri, album: 'Unknown Album', title: _extractTitleFromPath(media.uri), artist: 'Unknown Artist', ); }).toList(); _queueIndex = initialIndex; await updateQueue(mediaItems); } Future _addRandomTracksToQueue() async { if (_container == null) return; try { final database = _container!.read(databaseProvider); // Get paths of tracks already in queue to avoid duplicates final existingPaths = _queue.map((item) => item.id).toSet(); // Query for tracks not in current queue final allTracks = await (database.select( database.tracks, )..where((t) => t.path.isNotIn(existingPaths))).get(); // Shuffle and take 10 random tracks allTracks.shuffle(); final tracks = allTracks.take(10).toList(); if (tracks.isEmpty) return; // Convert to MediaItems final newMediaItems = await Future.wait(tracks.map(_trackToMediaItem)); // Add to queue _queue.addAll(newMediaItems); // Update the broadcasted queue queue.add(_queue); } catch (e) { // Silently handle errors to avoid interrupting playback debugPrint('Error adding random tracks to queue: $e'); } } String _extractTitleFromPath(String path) { return path.split('/').last.split('.').first; } void dispose() { _player.dispose(); } }