diff options
Diffstat (limited to 'lib/data/data_providers')
-rw-r--r-- | lib/data/data_providers/app_config_provider.dart | 50 | ||||
-rw-r--r-- | lib/data/data_providers/app_provider.dart | 46 | ||||
-rw-r--r-- | lib/data/data_providers/mediaplayer_notifier.dart | 25 | ||||
-rw-r--r-- | lib/data/data_providers/mediaplayer_position_notifier.dart | 29 | ||||
-rw-r--r-- | lib/data/data_providers/mpd_client.dart | 378 | ||||
-rw-r--r-- | lib/data/data_providers/play_controller.dart | 40 | ||||
-rw-r--r-- | lib/data/data_providers/playlist_art_notifier.dart | 20 | ||||
-rw-r--r-- | lib/data/data_providers/playlist_notifier.dart | 37 | ||||
-rw-r--r-- | lib/data/data_providers/radio_presets_provider.dart | 2 |
9 files changed, 619 insertions, 8 deletions
diff --git a/lib/data/data_providers/app_config_provider.dart b/lib/data/data_providers/app_config_provider.dart index a60a462..54d0e07 100644 --- a/lib/data/data_providers/app_config_provider.dart +++ b/lib/data/data_providers/app_config_provider.dart @@ -56,11 +56,27 @@ class RadioConfig { } } +class MpdConfig { + final String hostname; + final int port; + + static String defaultHostname = 'localhost'; + static int defaultPort = 6600; + + MpdConfig({required this.hostname, required this.port}); + + static MpdConfig defaultConfig() { + return MpdConfig( + hostname: MpdConfig.defaultHostname, port: MpdConfig.defaultPort); + } +} + class AppConfig { final bool disableBkgAnimation; final bool randomHybridAnimation; final KuksaConfig kuksaConfig; final RadioConfig radioConfig; + final MpdConfig mpdConfig; static String configFilePath = '/etc/xdg/AGL/ics-homescreen.yaml'; @@ -68,7 +84,8 @@ class AppConfig { {required this.disableBkgAnimation, required this.randomHybridAnimation, required this.kuksaConfig, - required this.radioConfig}); + required this.radioConfig, + required this.mpdConfig}); static KuksaConfig parseKuksaConfig(YamlMap kuksaMap) { try { @@ -160,6 +177,24 @@ class AppConfig { return RadioConfig.defaultConfig(); } } + + static MpdConfig parseMpdConfig(YamlMap mpdMap) { + try { + String hostname = MpdConfig.defaultHostname; + if (mpdMap.containsKey('hostname')) { + hostname = mpdMap['hostname']; + } + + int port = MpdConfig.defaultPort; + if (mpdMap.containsKey('port')) { + port = mpdMap['port']; + } + + return MpdConfig(hostname: hostname, port: port); + } catch (_) { + return MpdConfig.defaultConfig(); + } + } } final appConfigProvider = Provider((ref) { @@ -189,6 +224,13 @@ final appConfigProvider = Provider((ref) { radioConfig = RadioConfig.defaultConfig(); } + MpdConfig mpdConfig; + if (yamlMap.containsKey('mpd')) { + mpdConfig = AppConfig.parseMpdConfig(yamlMap['mpd']); + } else { + mpdConfig = MpdConfig.defaultConfig(); + } + bool disableBkgAnimation = disableBkgAnimationDefault; if (yamlMap.containsKey('disable-bg-animation')) { var value = yamlMap['disable-bg-animation']; @@ -209,12 +251,14 @@ final appConfigProvider = Provider((ref) { disableBkgAnimation: disableBkgAnimation, randomHybridAnimation: randomHybridAnimation, kuksaConfig: kuksaConfig, - radioConfig: radioConfig); + radioConfig: radioConfig, + mpdConfig: mpdConfig); } catch (_) { return AppConfig( disableBkgAnimation: false, randomHybridAnimation: false, kuksaConfig: KuksaConfig.defaultConfig(), - radioConfig: RadioConfig.defaultConfig()); + radioConfig: RadioConfig.defaultConfig(), + mpdConfig: MpdConfig.defaultConfig()); } }); diff --git a/lib/data/data_providers/app_provider.dart b/lib/data/data_providers/app_provider.dart index ad3dd22..3d00d4c 100644 --- a/lib/data/data_providers/app_provider.dart +++ b/lib/data/data_providers/app_provider.dart @@ -2,16 +2,23 @@ import 'package:flutter_ics_homescreen/data/data_providers/hybrid_notifier.dart' import 'package:flutter_ics_homescreen/data/data_providers/signal_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/time_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/units_notifier.dart'; -import 'package:flutter_ics_homescreen/data/data_providers/audio_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/users_notifier.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/vehicle_notifier.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/audio_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/radio_notifier.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/mediaplayer_notifier.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/mediaplayer_position_notifier.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/playlist_notifier.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/playlist_art_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/val_client.dart'; import 'package:flutter_ics_homescreen/data/data_providers/app_launcher.dart'; import 'package:flutter_ics_homescreen/data/data_providers/radio_client.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/mpd_client.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/play_controller.dart'; import 'package:flutter_ics_homescreen/export.dart'; -import '../models/users.dart'; -import 'vehicle_notifier.dart'; +import 'package:flutter_ics_homescreen/data/models/users.dart'; +import 'package:flutter_ics_homescreen/data/models/mediaplayer_state.dart'; enum AppState { home, @@ -59,6 +66,11 @@ final radioClientProvider = Provider((ref) { return RadioClient(config: config, ref: ref); }); +final mpdClientProvider = Provider((ref) { + MpdConfig config = ref.watch(appConfigProvider).mpdConfig; + return MpdClient(config: config, ref: ref); +}); + final vehicleProvider = NotifierProvider<VehicleNotifier, Vehicle>(VehicleNotifier.new); @@ -76,6 +88,34 @@ final audioStateProvider = final radioStateProvider = NotifierProvider<RadioStateNotifier, RadioState>(RadioStateNotifier.new); +final mediaPlayerStateProvider = + NotifierProvider<MediaPlayerStateNotifier, MediaPlayerState>( + MediaPlayerStateNotifier.new); + +final mediaPlayerPositionProvider = + NotifierProvider<MediaPlayerPositionNotifier, Duration>( + MediaPlayerPositionNotifier.new); + +final playlistProvider = + NotifierProvider<PlaylistNotifier, List<PlaylistEntry>>( + PlaylistNotifier.new); + +final playlistArtProvider = + NotifierProvider<PlaylistArtNotifier, Map<int, Uint8List>>( + PlaylistArtNotifier.new); + +final playStateProvider = StateProvider<bool>((ref) { + final mediaPlayState = ref.watch( + mediaPlayerStateProvider.select((mediaplayer) => mediaplayer.playState)); + final radioPlaying = + ref.watch(radioStateProvider.select((radio) => radio.playing)); + return (mediaPlayState == PlayState.playing || radioPlaying); +}); + +final playControllerProvider = Provider((ref) { + return PlayController(ref: ref); +}); + final usersProvider = StateNotifierProvider<UsersNotifier, Users>((ref) { return UsersNotifier(Users.initial()); }); diff --git a/lib/data/data_providers/mediaplayer_notifier.dart b/lib/data/data_providers/mediaplayer_notifier.dart new file mode 100644 index 0000000..2d02e75 --- /dev/null +++ b/lib/data/data_providers/mediaplayer_notifier.dart @@ -0,0 +1,25 @@ +import 'package:flutter_ics_homescreen/export.dart'; +import 'playlist_notifier.dart'; + +class MediaPlayerStateNotifier extends Notifier<MediaPlayerState> { + @override + MediaPlayerState build() { + return MediaPlayerState.initial(); + } + + void updatePlayState(PlayState newState) { + state = state.copyWith(playState: newState); + } + + void updatePlaylistPosition(int position) { + state = state.copyWith(playlistPosition: position); + } + + void updateCurrent(PlaylistEntry song) { + state = state.copyWith(song: song, playlistPosition: song.position); + } + + void reset() { + state = MediaPlayerState.initial(); + } +} diff --git a/lib/data/data_providers/mediaplayer_position_notifier.dart b/lib/data/data_providers/mediaplayer_position_notifier.dart new file mode 100644 index 0000000..be896da --- /dev/null +++ b/lib/data/data_providers/mediaplayer_position_notifier.dart @@ -0,0 +1,29 @@ +import 'dart:async'; +import 'package:flutter_ics_homescreen/export.dart'; + +class MediaPlayerPositionNotifier extends Notifier<Duration> { + Timer? positionTimer; + + @override + Duration build() { + return Duration.zero; + } + + void set(Duration position) { + state = position; + } + + void play() { + positionTimer ??= + Timer.periodic(const Duration(milliseconds: 250), (timer) { + state = state + const Duration(milliseconds: 250); + }); + } + + void pause() { + if (positionTimer != null) { + positionTimer!.cancel(); + positionTimer = null; + } + } +} diff --git a/lib/data/data_providers/mpd_client.dart b/lib/data/data_providers/mpd_client.dart new file mode 100644 index 0000000..98d8918 --- /dev/null +++ b/lib/data/data_providers/mpd_client.dart @@ -0,0 +1,378 @@ +import 'package:dart_mpd/dart_mpd.dart' as api; +import 'package:flutter_ics_homescreen/export.dart'; +import 'playlist_notifier.dart'; + +class ArtStateEntry { + bool reading = false; + bool read = false; + + ArtStateEntry({required this.reading, required this.read}); +} + +class MpdClient { + final MpdConfig config; + final Ref ref; + late api.MpdClient eventClient; + late api.MpdClient client; + Map<int, ArtStateEntry> artState = {}; + + MpdClient({required this.config, required this.ref}) { + debugPrint("Connecting to MPD at ${config.hostname}:${config.port}"); + client = api.MpdClient( + connectionDetails: api.MpdConnectionDetails( + host: config.hostname, + port: config.port, + timeout: const Duration(minutes: 2)), + onConnect: () => handleConnect(), + onDone: () => handleDone(), + onError: (e, st) => handleError(e, st)); + + // Second client instance to keep running in idle state to receive + // events + eventClient = api.MpdClient( + connectionDetails: api.MpdConnectionDetails( + host: config.hostname, + port: config.port, + timeout: const Duration(minutes: 2)), + onConnect: () => handleConnect(), + onDone: () => handleDone(), + onError: (e, st) => handleError(e, st)); + } + + void run() async { + var idleEvents = <api.MpdSubsystem>{ + api.MpdSubsystem.database, + api.MpdSubsystem.playlist, + api.MpdSubsystem.player + }; + bool done = false; + while (!done) { + //debugPrint("Calling MPD idle!"); + var events = await eventClient.idle(idleEvents); + for (var event in events) { + switch (event) { + case api.MpdSubsystem.database: + //debugPrint("Got MPD database event"); + await handleDatabaseEvent(); + break; + case api.MpdSubsystem.playlist: + //debugPrint("Got MPD queue event"); + await handleQueueEvent(); + break; + case api.MpdSubsystem.player: + //debugPrint("Got MPD player event"); + await handlePlayerEvent(); + break; + default: + break; + } + } + } + } + + void handleConnect() { + debugPrint("Connected to MPD!"); + } + + void handleResponse(api.MpdResponse response) { + debugPrint('Got MPD response $response'); + } + + void handleDone() { + debugPrint('Got MPD done!'); + } + + void handleError(Object e, StackTrace st) { + debugPrint('ERROR:\n$st'); + } + + // Idle state event handlers + + Future handleDatabaseEvent() async { + eventClient.clear(); + eventClient.add("/"); + } + + Future handleQueueEvent() async { + ref.read(playlistProvider.notifier).clear(); + artState.clear(); + ref.read(playlistArtProvider.notifier).clear(); + ref.read(mediaPlayerStateProvider.notifier).reset(); + + var songs = await eventClient.playlistinfo(); + for (var song in songs) { + //debugPrint("Got song ${song.title} - ${song.artist} at pos ${song.pos}"); + int position = 0; + if (song.pos != null) { + position = song.pos!; + } else { + //debugPrint("WARNING: song has no position in queue, ignoring"); + continue; + } + String title = ""; + if (song.title != null) { + title = song.title!.join(" "); + } else { + // Just use filename + title = song.file; + } + String album = ""; + if (song.album != null) { + album = song.album!.join(" "); + } + String artist = ""; + if (song.artist != null) { + artist = song.artist!.join(" "); + } + Duration duration = Duration.zero; + if (song.duration != null) { + duration = song.duration!; + } + //debugPrint( + // "Got playlist entry \"$title\" - \"$album\" - \"$artist\" at $position"); + + if (position == 0) { + ref.read(mediaPlayerStateProvider.notifier).updateCurrent(PlaylistEntry( + title: title, + album: album, + artist: artist, + file: song.file, + duration: duration, + position: position)); + + if (song.file.isNotEmpty) { + readSongArt(position, song.file); + } + } + ref.read(playlistProvider.notifier).add(PlaylistEntry( + title: title, + album: album, + artist: artist, + file: song.file, + duration: duration, + position: position)); + } + } + + Future handlePlayerEvent() async { + String songFile = ""; + int songPosition = -1; + var song = await eventClient.currentsong(); + if (song != null) { + //debugPrint( + // "Player event: song ${song.title} - ${song.artist} at pos ${song.pos}"); + if (song.pos != null) { + songPosition = song.pos!; + } + if (songPosition < 0) { + debugPrint("WARNING: song has no position in queue, ignoring"); + return; + } + String title = ""; + if (song.title != null) { + title = song.title!.first; + } else { + // Just use filename + title = song.file; + } + String album = ""; + if (song.album != null) { + album = song.album!.first; + } + String artist = ""; + if (song.artist != null) { + artist = song.artist!.first; + } + Duration duration = Duration.zero; + if (song.duration != null) { + duration = song.duration!; + } + songFile = song.file; + //debugPrint( + // "Got song \"$title\" - \"$album\" - \"$artist\" at $songPosition, file \"$songFile\""); + ref.read(mediaPlayerStateProvider.notifier).updateCurrent(PlaylistEntry( + title: title, + album: album, + artist: artist, + file: songFile, + duration: duration, + position: songPosition)); + } + + var status = await eventClient.status(); + if (status.elapsed != null) { + //debugPrint("Using elapsed time ${status.elapsed} s"); + ref + .read(mediaPlayerPositionProvider.notifier) + .set(Duration(milliseconds: (status.elapsed! * 1000.0).toInt())); + } + PlayState playState = PlayState.stopped; + if (status.state != null) { + switch (status.state!) { + case api.MpdState.stop: + //debugPrint("status.state = stop"); + playState = PlayState.stopped; + ref.read(mediaPlayerPositionProvider.notifier).pause(); + break; + case api.MpdState.play: + //debugPrint("status.state = play"); + playState = PlayState.playing; + ref.read(mediaPlayerPositionProvider.notifier).play(); + break; + case api.MpdState.pause: + //debugPrint("status.state = paused"); + playState = PlayState.paused; + ref.read(mediaPlayerPositionProvider.notifier).pause(); + break; + default: + break; + } + ref.read(mediaPlayerStateProvider.notifier).updatePlayState(playState); + } + + if (playState != PlayState.playing) { + // No need to attempt to load art, exit + return; + } + + if (!artState.containsKey(songPosition) || + !(artState[songPosition]!.read || artState[songPosition]!.reading)) { + if (songFile.isNotEmpty) { + readSongArt(songPosition, songFile); + } else { + // Do not attempt any fallback for providing the art for now + debugPrint("No file for position $songPosition, no art"); + artState[songPosition] = ArtStateEntry(reading: false, read: true); + ref + .read(playlistArtProvider.notifier) + .update(songPosition, Uint8List(0)); + } + } + } + + void readSongArt(int position, String file) async { + int offset = 0; + int size = 0; + List<int> bytes = []; + + if (artState.containsKey(position)) { + if (artState[position]!.reading) { + return; + } else { + //debugPrint("Reading art for position $position"); + artState[position]!.reading = true; + } + } else { + artState[position] = ArtStateEntry(reading: true, read: false); + } + debugPrint("Reading art for \"$file\""); + // Work around dart_mpd not escaping spaced strings itself + String escapedFile = file.replaceAll(RegExp(r" "), "\\ "); + escapedFile = "\"$file\""; + + bool first = true; + do { + //debugPrint("Reading, offset = $offset, size = $size"); + var chunk = await client.readpicture(escapedFile, offset); + if (chunk != null) { + if (chunk.size != null) { + if (chunk.size == 0) { + if (first) { + // No art, exit + break; + } + } + size = chunk.size!; + } + // else unexpected error + + if (chunk.bytes.isNotEmpty) { + //debugPrint("Got ${chunk.bytes.length} bytes of album art"); + bytes = bytes + chunk.bytes; + offset += chunk.bytes.length; + } else { + break; + } + } + } while (offset < size); + //debugPrint("Done, offset = $offset, size = $size"); + + if (offset == size) { + debugPrint("Read $size bytes of album art for $file"); + } else { + // else error, leave art empty + bytes.clear(); + } + + artState[position]!.read = true; + artState[position]!.reading = false; + ref + .read(playlistArtProvider.notifier) + .update(position, Uint8List.fromList(bytes)); + } + + // Player commands + + void play() async { + var playState = ref.read(mediaPlayerStateProvider + .select((mediaplayer) => mediaplayer.playState)); + if (playState == PlayState.stopped) { + int position = ref.read(mediaPlayerStateProvider + .select((mediaplayer) => mediaplayer.playlistPosition)); + //debugPrint("Calling MPD play, position = $position"); + if (position >= 0) { + client.play(position); + } + } else if (playState == PlayState.paused) { + client.pause(false); + } + } + + void pause() async { + if (ref.read(mediaPlayerStateProvider + .select((mediaplayer) => mediaplayer.playState)) == + PlayState.playing) { + client.pause(true); + } + } + + void next() async { + if (ref.read(mediaPlayerStateProvider + .select((mediaplayer) => mediaplayer.playState)) == + PlayState.playing) { + client.next(); + } + } + + void previous() async { + if (ref.read(mediaPlayerStateProvider + .select((mediaplayer) => mediaplayer.playState)) == + PlayState.playing) { + client.previous(); + } + } + + void seek(int milliseconds) async { + client.seekcur((milliseconds / 1000.0).toString()); + } + + void fastForward(int milliseconds) async { + if (milliseconds > 0) { + client.seekcur("+${(milliseconds / 1000.0).toString()}"); + } + } + + void rewind(int milliseconds) async { + if (milliseconds > 0) { + client.seekcur("-${(milliseconds / 1000.0).toString()}"); + } + } + + void pickTrack(int position) async { + if (position >= 0) { + client.play(position); + } + } + + void loopPlaylist(bool loop) async {} +} diff --git a/lib/data/data_providers/play_controller.dart b/lib/data/data_providers/play_controller.dart new file mode 100644 index 0000000..38ef309 --- /dev/null +++ b/lib/data/data_providers/play_controller.dart @@ -0,0 +1,40 @@ +import 'package:flutter_ics_homescreen/export.dart'; + +enum PlaySource { none, media, radio } + +class PlayController { + final Ref ref; + PlaySource source = PlaySource.none; + + PlayController({required this.ref}); + + void setSource(PlaySource newSource) { + source = newSource; + } + + void play() async { + switch (source) { + case PlaySource.media: + ref.read(mpdClientProvider).play(); + break; + case PlaySource.radio: + ref.read(radioClientProvider).start(); + break; + default: + break; + } + } + + void pause() async { + switch (source) { + case PlaySource.media: + ref.read(mpdClientProvider).pause(); + break; + case PlaySource.radio: + ref.read(radioClientProvider).stop(); + break; + default: + break; + } + } +} diff --git a/lib/data/data_providers/playlist_art_notifier.dart b/lib/data/data_providers/playlist_art_notifier.dart new file mode 100644 index 0000000..417aa9a --- /dev/null +++ b/lib/data/data_providers/playlist_art_notifier.dart @@ -0,0 +1,20 @@ +import 'package:flutter_ics_homescreen/export.dart'; + +class PlaylistArtNotifier extends Notifier<Map<int, Uint8List>> { + @override + Map<int, Uint8List> build() { + return {}; + } + + void update(int position, Uint8List art) { + // Having to copy the Map to trigger notification seems not particularly + // efficient, it may make sense to notify against the last position + // updated or similar... + state[position] = art; + state = Map.of(state); + } + + void clear() { + state = {}; + } +} diff --git a/lib/data/data_providers/playlist_notifier.dart b/lib/data/data_providers/playlist_notifier.dart new file mode 100644 index 0000000..2936591 --- /dev/null +++ b/lib/data/data_providers/playlist_notifier.dart @@ -0,0 +1,37 @@ +import 'package:flutter_ics_homescreen/export.dart'; + +class PlaylistEntry { + final String title; + final String album; + final String artist; + final String file; + final Duration duration; + final int position; + + const PlaylistEntry( + {required this.title, + required this.album, + required this.artist, + required this.file, + required this.duration, + required this.position}); +} + +class PlaylistNotifier extends Notifier<List<PlaylistEntry>> { + @override + List<PlaylistEntry> build() { + return []; + } + + void update({required List<PlaylistEntry> newPlaylist}) { + state = newPlaylist; + } + + void add(PlaylistEntry entry) { + state.add(entry); + } + + void clear() { + state = []; + } +} diff --git a/lib/data/data_providers/radio_presets_provider.dart b/lib/data/data_providers/radio_presets_provider.dart index 9ee68ac..af14997 100644 --- a/lib/data/data_providers/radio_presets_provider.dart +++ b/lib/data/data_providers/radio_presets_provider.dart @@ -1,6 +1,4 @@ -import 'dart:io'; import 'package:flutter_ics_homescreen/export.dart'; -import 'package:yaml/yaml.dart'; class RadioPreset { final int frequency; |