From 2d395f4431ba4aa5055d02437463588f4d4c8127 Mon Sep 17 00:00:00 2001 From: Scott Murray Date: Wed, 3 Jan 2024 18:14:12 -0500 Subject: Initial mediaplayer implementation Notable changes: - Added dart_mpd package as a dependency. - Added MPD client class and associated provider. - Added MPD client configuration to the configuration file for potential usecases where MPD may not be available locally. - Added playlist, play state, art, etc. providers for use in the mediaplayer front end UI. - Reworked MediaPlayer classes to wire up MPD client backend. - Removed volume slider from the bottom of the media pages to make more room for playlist / preset tables, as only being able to show 3 entries in each was not usable in practice. - Reworked media player mocked up position indicator into an actual slider control, and reworked the radio slider styling to match for better UI consistency. - Reworked media player and radio playlist / preset tables to attempt to have them layout at the same location and add an always visible scroll bar. The scroll bars currently have a layout issue with respect to scroll track size that does not seem to have an obvious fix. They are usable for now, and further investigation will be done when time permits. - Wired up play/pause button on the side volume control via a new set of play state and controller providers. Bug-AGL: SPEC-5028, SPEC-5029 Change-Id: I87efecc58b4e185443942eb32ff8148ebcd675c3 Signed-off-by: Scott Murray --- lib/data/data_providers/app_config_provider.dart | 50 ++- lib/data/data_providers/app_provider.dart | 46 ++- lib/data/data_providers/mediaplayer_notifier.dart | 25 ++ .../mediaplayer_position_notifier.dart | 29 ++ lib/data/data_providers/mpd_client.dart | 378 +++++++++++++++++++++ lib/data/data_providers/play_controller.dart | 40 +++ lib/data/data_providers/playlist_art_notifier.dart | 20 ++ lib/data/data_providers/playlist_notifier.dart | 37 ++ .../data_providers/radio_presets_provider.dart | 2 - 9 files changed, 619 insertions(+), 8 deletions(-) create mode 100644 lib/data/data_providers/mediaplayer_notifier.dart create mode 100644 lib/data/data_providers/mediaplayer_position_notifier.dart create mode 100644 lib/data/data_providers/mpd_client.dart create mode 100644 lib/data/data_providers/play_controller.dart create mode 100644 lib/data/data_providers/playlist_art_notifier.dart create mode 100644 lib/data/data_providers/playlist_notifier.dart (limited to 'lib/data/data_providers') 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.new); @@ -76,6 +88,34 @@ final audioStateProvider = final radioStateProvider = NotifierProvider(RadioStateNotifier.new); +final mediaPlayerStateProvider = + NotifierProvider( + MediaPlayerStateNotifier.new); + +final mediaPlayerPositionProvider = + NotifierProvider( + MediaPlayerPositionNotifier.new); + +final playlistProvider = + NotifierProvider>( + PlaylistNotifier.new); + +final playlistArtProvider = + NotifierProvider>( + PlaylistArtNotifier.new); + +final playStateProvider = StateProvider((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((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 { + @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 { + 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 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.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 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> { + @override + Map 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> { + @override + List build() { + return []; + } + + void update({required List 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; -- cgit 1.2.3-korg