diff --git a/README.md b/README.md index 2b69cf4..5966f34 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The plugins for elinux are basically designed to be API compatible with the offi | ------------------ | ---------------- | | [video_player_elinux](packages/video_player) | [video_player](https://github.com/flutter/packages/tree/main/packages/video_player/video_player) | | [camera_elinux](packages/camera) | [camera](https://github.com/flutter/packages/tree/main/packages/camera/camera) | +| [audioplayers_elinux](packages/audioplayers) | [audioplayers](https://github.com/bluefireteam/audioplayers/tree/main/packages/audioplayers) | [path_provider_elinux](packages/path_provider) | [path_provider](https://github.com/flutter/packages/tree/main/packages/path_provider) | | [shared_preferences_elinux](packages/shared_preferences) | [shared_preferences](https://github.com/flutter/packages/tree/main/packages/shared_preferences) | | [joystick](packages/joystick) | - | diff --git a/packages/audioplayers/.gitignore b/packages/audioplayers/.gitignore new file mode 100644 index 0000000..0d5045c --- /dev/null +++ b/packages/audioplayers/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.dart_tool/ + +.packages +.pub/ + +build/ \ No newline at end of file diff --git a/packages/audioplayers/CHANGELOG.md b/packages/audioplayers/CHANGELOG.md new file mode 100644 index 0000000..30c14fa --- /dev/null +++ b/packages/audioplayers/CHANGELOG.md @@ -0,0 +1,2 @@ +## 0.1.0 +* First draft version. diff --git a/packages/audioplayers/LICENSE b/packages/audioplayers/LICENSE new file mode 100644 index 0000000..57694af --- /dev/null +++ b/packages/audioplayers/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2024 Sony Group Corporation. All rights reserved. +Copyright (c) 2013 The Flutter Authors. All rights reserved. +Copyright (c) 2017 Luan Nico + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the names of the copyright holders nor the names of the + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/audioplayers/README.md b/packages/audioplayers/README.md new file mode 100644 index 0000000..438edf6 --- /dev/null +++ b/packages/audioplayers/README.md @@ -0,0 +1,35 @@ +# audioplayers_elinux + +The implementation of the Audio Player plugin for flutter elinux. APIs are designed to be API compatible with the the official [`audioplayers`](https://github.com/bluefireteam/audioplayers/tree/main/packages/audioplayers). + +## Required libraries + +This plugin uses [GStreamer](https://gstreamer.freedesktop.org/) internally. + +```Shell +$ sudo apt install libglib2.0-dev +$ sudo apt install libgstreamer1.0-dev +# Install as needed. +$ sudo apt install libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav +``` + +## Usage + +### pubspec.yaml +```yaml +dependencies: + audioplayers: ^6.0.0 + audioplayers_elinux: + git: + url: https://github.com/sony/flutter-elinux-plugins.git + path: packages/audioplayers + ref: main +``` + +### Source code +Import `audioplayers` in your Dart code: +```dart +import 'package:audioplayers/audioplayers.dart'; +``` diff --git a/packages/audioplayers/elinux/CMakeLists.txt b/packages/audioplayers/elinux/CMakeLists.txt new file mode 100644 index 0000000..bbe5971 --- /dev/null +++ b/packages/audioplayers/elinux/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.15) +set(PROJECT_NAME "audioplayers_elinux") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "audioplayers_elinux_plugin") + +find_package(PkgConfig) +pkg_check_modules(GLIB REQUIRED glib-2.0) +pkg_check_modules(GSTREAMER REQUIRED gstreamer-1.0) + +add_library(${PLUGIN_NAME} SHARED + "audioplayers_elinux_plugin.cc" + "gst_audio_player.cc" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +target_include_directories(${PLUGIN_NAME} + PRIVATE + ${GLIB_INCLUDE_DIRS} + ${GSTREAMER_INCLUDE_DIRS} +) + +target_link_libraries(${PLUGIN_NAME} + PRIVATE + ${GLIB_LIBRARIES} + ${GSTREAMER_LIBRARIES} +) + +# List of absolute paths to libraries that should be bundled with the plugin +set(audioplayers_elinux_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/audioplayers/elinux/audio_player_stream_handler.h b/packages/audioplayers/elinux/audio_player_stream_handler.h new file mode 100644 index 0000000..bca0f10 --- /dev/null +++ b/packages/audioplayers/elinux/audio_player_stream_handler.h @@ -0,0 +1,55 @@ +// Copyright 2024 Sony Group Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_AUDIO_PLAYER_STREAM_HANDLER_H_ +#define PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_AUDIO_PLAYER_STREAM_HANDLER_H_ + +#include + +class AudioPlayerStreamHandler { + public: + AudioPlayerStreamHandler() = default; + virtual ~AudioPlayerStreamHandler() = default; + + // Prevent copying. + AudioPlayerStreamHandler(AudioPlayerStreamHandler const&) = delete; + AudioPlayerStreamHandler& operator=(AudioPlayerStreamHandler const&) = delete; + + // Notifies the completion of preparation the audio player. + void OnNotifyPrepared(const std::string &player_id, + const bool is_prepared) { + OnNotifyPreparedInternal(player_id, is_prepared); + } + + // Notifies the duration of an audio. + void OnNotifyDuration(const std::string &player_id, + const int32_t duration) { + OnNotifyDurationInternal(player_id, duration); + } + + // Notifies the completion of seeks an audio. + void OnNotifySeekCompleted(const std::string &player_id) { + OnNotifySeekCompletedInternal(player_id); + } + + // Notifies the completion of playing an audio. + void OnNotifyPlayCompleted(const std::string &player_id) { + OnNotifyPlayCompletedInternal(player_id); + } + + // Notifies the log of the audio player. + void OnNotifyLog(const std::string &player_id, + const std::string &message) { + OnNotifyLogInternal(player_id, message); + } + + protected: + virtual void OnNotifyPreparedInternal(const std::string&, const bool) = 0; + virtual void OnNotifyDurationInternal(const std::string&, const int32_t) = 0; + virtual void OnNotifySeekCompletedInternal(const std::string&) = 0; + virtual void OnNotifyPlayCompletedInternal(const std::string &) = 0; + virtual void OnNotifyLogInternal(const std::string&, const std::string&) = 0; +}; + +#endif // PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_AUDIO_PLAYER_STREAM_HANDLER_H_ diff --git a/packages/audioplayers/elinux/audio_player_stream_handler_impl.h b/packages/audioplayers/elinux/audio_player_stream_handler_impl.h new file mode 100644 index 0000000..86d176a --- /dev/null +++ b/packages/audioplayers/elinux/audio_player_stream_handler_impl.h @@ -0,0 +1,85 @@ +// Copyright 2024 Sony Group Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_AUDIO_PLAYER_STREAM_HANDLER_IMPL_H_ +#define PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_AUDIO_PLAYER_STREAM_HANDLER_IMPL_H_ + +#include + +#include "audio_player_stream_handler.h" + +class AudioPlayerStreamHandlerImpl : public AudioPlayerStreamHandler { + public: + using OnNotifyPrepared = std::function; + using OnNotifyDuration = + std::function; + using OnNotifySeekCompleted = std::function; + using OnNotifyPlayCompleted = std::function; + using OnNotifyLog = + std::function; + + AudioPlayerStreamHandlerImpl(OnNotifyPrepared on_notify_prepared, + OnNotifyDuration on_notify_duration, + OnNotifySeekCompleted on_notify_seek_completed, + OnNotifyPlayCompleted on_notify_play_completed, + OnNotifyLog on_notify_log) + : on_notify_prepared_(on_notify_prepared), + on_notify_duration_(on_notify_duration), + on_notify_seek_completed_(on_notify_seek_completed), + on_notify_play_completed_(on_notify_play_completed), + on_notify_log_(on_notify_log) {} + virtual ~AudioPlayerStreamHandlerImpl() = default; + + // Prevent copying. + AudioPlayerStreamHandlerImpl(AudioPlayerStreamHandlerImpl const&) = delete; + AudioPlayerStreamHandlerImpl& operator=(AudioPlayerStreamHandlerImpl const&) = + delete; + + protected: + // |AudioPlayerStreamHandler| + void OnNotifyPreparedInternal(const std::string &player_id, + const bool is_prepared) { + if (on_notify_prepared_) { + on_notify_prepared_(player_id, is_prepared); + } + } + + // |AudioPlayerStreamHandler| + void OnNotifyDurationInternal(const std::string &player_id, + const int32_t duration) { + if (on_notify_duration_) { + on_notify_duration_(player_id, duration); + } + } + + // |AudioPlayerStreamHandler| + void OnNotifySeekCompletedInternal(const std::string &player_id) { + if (on_notify_seek_completed_) { + on_notify_seek_completed_(player_id); + } + } + + // |AudioPlayerStreamHandler| + void OnNotifyPlayCompletedInternal(const std::string &player_id) { + if (on_notify_play_completed_) { + on_notify_play_completed_(player_id); + } + } + + // |AudioPlayerStreamHandler| + void OnNotifyLogInternal(const std::string &player_id, + const std::string &message) { + if (on_notify_log_) { + on_notify_log_(player_id, message); + } + } + + OnNotifyPrepared on_notify_prepared_; + OnNotifyDuration on_notify_duration_; + OnNotifySeekCompleted on_notify_seek_completed_; + OnNotifyPlayCompleted on_notify_play_completed_; + OnNotifyLog on_notify_log_; +}; + +#endif // PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_AUDIO_PLAYER_STREAM_HANDLER_IMPL_H_ diff --git a/packages/audioplayers/elinux/audioplayers_elinux_plugin.cc b/packages/audioplayers/elinux/audioplayers_elinux_plugin.cc new file mode 100644 index 0000000..1884641 --- /dev/null +++ b/packages/audioplayers/elinux/audioplayers_elinux_plugin.cc @@ -0,0 +1,307 @@ +// Copyright 2024 Sony Group Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "include/audioplayers_elinux/audioplayers_elinux_plugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "gst_audio_player.h" +#include "audio_player_stream_handler_impl.h" + +namespace { +constexpr char kInvalidArgument[] = "Invalid argument"; +constexpr char kAudioDurationEvent[] = "audio.onDuration"; +constexpr char kAudioPreparedEvent[] = "audio.onPrepared"; +constexpr char kAudioSeekCompleteEvent[] = "audio.onSeekComplete"; +constexpr char kAudioCompleteEvent[] = "audio.onComplete"; +constexpr char kAudioLogEvent[] = "audio.onLog"; + +template +bool GetValueFromEncodableMap(const flutter::EncodableMap* map, const char* key, + T &out) { + auto iter = map->find(flutter::EncodableValue(key)); + if (iter != map->end() && !iter->second.IsNull()) { + if (auto* value = std::get_if(&iter->second)) { + out = *value; + return true; + } + } + return false; +} + +class AudioplayersElinuxPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrar* registrar) { + auto plugin = std::make_unique(registrar); + { + auto channel = + std::make_unique>( + registrar->messenger(), "xyz.luan/audioplayers", + &flutter::StandardMethodCodec::GetInstance()); + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + } + { + auto channel = + std::make_unique>( + registrar->messenger(), "xyz.luan/audioplayers.global", + &flutter::StandardMethodCodec::GetInstance()); + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) { + plugin_pointer->HandleGlobalMethodCall(call, std::move(result)); + }); + } + registrar->AddPlugin(std::move(plugin)); + } + + AudioplayersElinuxPlugin(flutter::PluginRegistrar* registrar) + : registrar_(registrar) { + GstAudioPlayer::GstLibraryLoad(); + } + + virtual ~AudioplayersElinuxPlugin() {} + + void SetRegistrar(flutter::PluginRegistrar* registrar) { + registrar_ = registrar; + } + + private: + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const auto* arguments = + std::get_if(method_call.arguments()); + if (!arguments) { + result->Error(kInvalidArgument, "No arguments provided."); + return; + } + + std::string player_id; + if (!GetValueFromEncodableMap(arguments, "playerId", player_id)) { + result->Error(kInvalidArgument, "No playerId provided."); + return; + } + + const std::string &method_name = method_call.method_name(); + if (method_name == "create") { + CreateAudioPlayer(player_id); + result->Success(); + return; + } + + GstAudioPlayer* player = GetAudioPlayer(player_id); + if (!player) { + result->Error(kInvalidArgument, + "No AudioPlayer" + player_id + " is exist."); + return; + } + + if (method_name == "resume") { + player->Resume(); + result->Success(); + } else if (method_name == "pause") { + player->Pause(); + result->Success(); + } else if (method_name == "stop") { + player->Stop(); + result->Success(); + } else if (method_name == "release") { + player->Release(); + result->Success(); + } else if (method_name == "seek") { + int32_t position = 0; + GetValueFromEncodableMap(arguments, "position", position); + player->Seek(position); + result->Success(); + } else if (method_name == "setVolume") { + double volume = 0; + GetValueFromEncodableMap(arguments, "volume", volume); + player->SetVolume(volume); + result->Success(); + } else if (method_name == "setSourceUrl") { + bool is_local = false; + GetValueFromEncodableMap(arguments, "isLocal", is_local); + std::string url = ""; + GetValueFromEncodableMap(arguments, "url", url); + if (is_local) { + url = std::string("file://") + url; + } + player->SetSourceUrl(url); + result->Success(); + } else if (method_name == "setPlaybackRate") { + double rate = 0; + GetValueFromEncodableMap(arguments, "playbackRate", rate); + player->SetPlaybackRate(rate); + result->Success(); + } else if (method_name == "setReleaseMode") { + std::string release_mode = ""; + GetValueFromEncodableMap(arguments, "releaseMode", release_mode); + bool looping = release_mode.find("loop") != std::string::npos; + player->SetLooping(looping); + result->Success(); + } else if (method_name == "getDuration") { + int64_t duration = player->GetDuration(); + if (duration >= 0) { + result->Success(flutter::EncodableValue(duration)); + } else { + result->Success(); + } + } else if (method_name == "getCurrentPosition") { + int64_t position = player->GetCurrentPosition(); + if (position >= 0) { + result->Success(flutter::EncodableValue(position)); + } else { + result->Success(); + } + } else if (method_name == "setBalance") { + double balance = 0; + GetValueFromEncodableMap(arguments, "balance", balance); + player->SetBalance(balance); + result->Success(); + } + else if (method_name == "setPlayerMode") { + result->NotImplemented(); + } else if (method_name == "setAudioContext") { + result->NotImplemented(); + } else if (method_name == "emitLog") { + result->NotImplemented(); + } else if (method_name == "emitError") { + result->NotImplemented(); + } else if (method_name == "dispose") { + player->Dispose(); + audio_players_.erase(player_id); + event_sinks_.erase(player_id); + result->Success(); + } else { + result->NotImplemented(); + } + } + + void HandleGlobalMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const std::string &method_name = method_call.method_name(); + if (method_name == "setAudioContext") { + result->NotImplemented(); + } else if (method_name == "emitLog") { + result->NotImplemented(); + } else if (method_name == "emitError") { + result->NotImplemented(); + } else { + result->NotImplemented(); + } + } + + GstAudioPlayer* GetAudioPlayer(const std::string &player_id) { + auto iter = audio_players_.find(player_id); + if (iter != audio_players_.end()) { + return iter->second.get(); + } + return nullptr; + } + + void CreateAudioPlayer(const std::string &player_id) { + auto event_channel = + std::make_unique>( + registrar_->messenger(), + "xyz.luan/audioplayers/events/" + player_id, + &flutter::StandardMethodCodec::GetInstance()); + auto event_channel_handler = std::make_unique< + flutter::StreamHandlerFunctions>( + // StreamHandlerFunctions + [this, id = player_id]( + const flutter::EncodableValue* arguments, + std::unique_ptr>&& + events) + -> std::unique_ptr< + flutter::StreamHandlerError> { + this->event_sinks_[id] = std::move(events); + return nullptr; + }, + // StreamHandlerCancel + [](const flutter::EncodableValue* arguments) + -> std::unique_ptr< + flutter::StreamHandlerError> { + return nullptr; + }); + event_channel->SetStreamHandler(std::move(event_channel_handler)); + + auto player_handler = std::make_unique( + // OnNotifyPrepared + [this](const std::string &player_id, bool is_prepared) { + flutter::EncodableMap map = { + {flutter::EncodableValue("event"), + flutter::EncodableValue(kAudioPreparedEvent)}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(is_prepared)}}; + event_sinks_[player_id]->Success(flutter::EncodableValue(map)); + }, + // OnNotifyDuration + [this](const std::string &player_id, int32_t duration) { + flutter::EncodableMap map = { + {flutter::EncodableValue("event"), + flutter::EncodableValue(kAudioDurationEvent)}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(duration)}}; + event_sinks_[player_id]->Success(flutter::EncodableValue(map)); + }, + // OnNotifySeekCompleted + [this](const std::string &player_id) { + flutter::EncodableMap map = { + {flutter::EncodableValue("event"), + flutter::EncodableValue(kAudioSeekCompleteEvent)}}; + event_sinks_[player_id]->Success(flutter::EncodableValue(map)); + }, + // OnNotifyPlayCompleted + [this](const std::string &player_id) { + flutter::EncodableMap map = { + {flutter::EncodableValue("event"), + flutter::EncodableValue(kAudioCompleteEvent)}}; + event_sinks_[player_id]->Success(flutter::EncodableValue(map)); + }, + // OnNotifyLog + [this](const std::string &player_id, const std::string &message) { + flutter::EncodableMap map = { + {flutter::EncodableValue("event"), + flutter::EncodableValue(kAudioLogEvent)}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(message)}}; + event_sinks_[player_id]->Success(flutter::EncodableValue(map)); + }); + + auto player = + std::make_unique(player_id, std::move(player_handler)); + audio_players_[player_id] = std::move(player); + } + + std::map> audio_players_; + std::map>> + event_sinks_; + flutter::PluginRegistrar* registrar_; +}; + +} // namespace + +void AudioplayersElinuxPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + AudioplayersElinuxPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/packages/audioplayers/elinux/gst_audio_player.cc b/packages/audioplayers/elinux/gst_audio_player.cc new file mode 100644 index 0000000..be4168f --- /dev/null +++ b/packages/audioplayers/elinux/gst_audio_player.cc @@ -0,0 +1,370 @@ +// Copyright 2024 Sony Group Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "gst_audio_player.h" + +#include + +GstAudioPlayer::GstAudioPlayer( + const std::string &player_id, + std::unique_ptr handler) + : player_id_(player_id), + stream_handler_(std::move(handler)) { + gst_.playbin = nullptr; + gst_.bus = nullptr; + gst_.source = nullptr; + gst_.panorama = nullptr; + gst_.audiobin = nullptr; + gst_.audiosink = nullptr; + gst_.panoramasinkpad = nullptr; + + if (!CreatePipeline()) { + std::cerr << "Failed to create a pipeline" << std::endl; + Dispose(); + return; + } +} + +GstAudioPlayer::~GstAudioPlayer() { + Stop(); + Dispose(); +} + +// static +void GstAudioPlayer::GstLibraryLoad() { gst_init(NULL, NULL); } + +// static +void GstAudioPlayer::GstLibraryUnload() { gst_deinit(); } + +// Creates a audio playbin. +// $ playbin uri= +bool GstAudioPlayer::CreatePipeline() { + gst_.playbin = gst_element_factory_make("playbin", "playbin"); + if (!gst_.playbin) { + std::cerr << "Failed to create a playbin" << std::endl; + return false; + } + + // Setup stereo balance controller + gst_.panorama = gst_element_factory_make("audiopanorama", "audiopanorama"); + if (gst_.panorama) { + gst_.audiobin = gst_bin_new(NULL); + gst_.audiosink = gst_element_factory_make("autoaudiosink", "autoaudiosink"); + + gst_bin_add_many(GST_BIN(gst_.audiobin), gst_.panorama, gst_.audiosink, NULL); + gst_element_link(gst_.panorama, gst_.audiosink); + + GstPad* sinkpad = gst_element_get_static_pad(gst_.panorama, "sink"); + gst_.panoramasinkpad = gst_ghost_pad_new("sink", sinkpad); + gst_element_add_pad(gst_.audiobin, gst_.panoramasinkpad); + gst_object_unref(GST_OBJECT(sinkpad)); + + g_object_set(G_OBJECT(gst_.playbin), "audio-sink", gst_.audiobin, NULL); + g_object_set(G_OBJECT(gst_.panorama), "method", 1, NULL); + } + + // Setup source options + g_signal_connect(gst_.playbin, "source-setup", + G_CALLBACK(GstAudioPlayer::SourceSetup), &gst_.source); + + // Watch bus messages for one time events + gst_.bus = gst_pipeline_get_bus(GST_PIPELINE(gst_.playbin)); + gst_bus_set_sync_handler(gst_.bus, HandleGstMessage, this, NULL); + + return true; +} + +// static +void GstAudioPlayer::SourceSetup(GstElement* playbin, + GstElement* source, + GstElement** p_src) { + // Allow sources from unencrypted / misconfigured connections + if (g_object_class_find_property( + G_OBJECT_GET_CLASS(source), "ssl-strict") != 0) { + g_object_set(G_OBJECT(source), "ssl-strict", FALSE, NULL); + } +} + +std::string GstAudioPlayer::ParseUri(const std::string& uri) { + if (gst_uri_is_valid(uri.c_str())) { + return uri; + } + + const auto* filename_uri = gst_filename_to_uri(uri.c_str(), NULL); + if (!filename_uri) { + std::cerr << "Faild to open " << uri.c_str() << std::endl; + return uri; + } + std::string result_uri(filename_uri); + delete filename_uri; + + return result_uri; +} + +void GstAudioPlayer::Resume() { + if (!is_playing_) { + is_playing_ = true; + } + + if (!is_initialized_) { + return; + } + + if (gst_element_set_state(gst_.playbin, GST_STATE_PLAYING) == + GST_STATE_CHANGE_FAILURE) { + std::cerr << "Unable to set the pipeline to GST_STATE_PLAYING" << std::endl; + return; + } + int64_t duration = GetDuration(); + stream_handler_->OnNotifyDuration(player_id_, duration); +} + +void GstAudioPlayer::Play() { + Seek(0); + Resume(); +} + +void GstAudioPlayer::Pause() { + if (is_playing_) { + is_playing_ = false; + } + if (!is_initialized_) { + return; + } + if (gst_element_set_state(gst_.playbin, GST_STATE_PAUSED) == + GST_STATE_CHANGE_FAILURE) { + std::cerr << "Failed to change the state to PAUSED" << std::endl; + return; + } +} + +void GstAudioPlayer::Stop() { + Pause(); + if (!is_initialized_) { + return; + } + Seek(0); +} + +void GstAudioPlayer::Seek(int64_t position) { + if (!is_initialized_) { + return; + } + auto nanosecond = position * 1000 * 1000; + if (!gst_element_seek( + gst_.playbin, playback_rate_, GST_FORMAT_TIME, + (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_KEY_UNIT), + GST_SEEK_TYPE_SET, nanosecond, GST_SEEK_TYPE_SET, + GST_CLOCK_TIME_NONE)) { + std::cerr << "Failed to seek " << position << std::endl; + return; + } + stream_handler_->OnNotifySeekCompleted(player_id_); +} + +void GstAudioPlayer::SetSourceUrl(std::string url) { + if (url_ != url) { + url_ = url; + + // flush unhandled messeges + gst_bus_set_flushing(gst_.bus, TRUE); + gst_element_set_state(gst_.playbin, GST_STATE_NULL); + is_playing_ = false; + if (!url_.empty()) { + g_object_set(GST_OBJECT(gst_.playbin), "uri", url_.c_str(), NULL); + if (gst_.playbin->current_state != GST_STATE_READY) { + GstStateChangeReturn ret = + gst_element_set_state(gst_.playbin, GST_STATE_READY); + if (ret == GST_STATE_CHANGE_FAILURE) { + std::cerr << + "Unable to set the pipeline to GST_STATE_READY." << std::endl; + } + } + } + is_initialized_ = true; + } + stream_handler_->OnNotifyPrepared(player_id_, true); +} + +void GstAudioPlayer::SetVolume(double volume) { + if (volume > 1) { + volume = 1; + } else if (volume < 0) { + volume = 0; + } + volume_ = volume; + g_object_set(gst_.playbin, "volume", volume, NULL); +} + +void GstAudioPlayer::SetBalance(double balance) { + if (!gst_.panorama) { + std::cerr << "Audiopanorama was not initialized" << std::endl; + return; + } + + if (balance > 1.0) { + balance = 1.0; + } else if (balance < -1.0) { + balance = -1.0; + } + g_object_set(G_OBJECT(gst_.panorama), "panorama", balance, NULL); +} + +void GstAudioPlayer::SetPlaybackRate(double playback_rate) { + if (playback_rate <= 0) { + std::cerr << "Rate " << playback_rate << " is not supported" << std::endl; + return; + } + + if (!is_initialized_) { + return; + } + int64_t position = GetCurrentPosition(); + if (!gst_element_seek(gst_.playbin, playback_rate, GST_FORMAT_TIME, + GST_SEEK_FLAG_FLUSH, GST_SEEK_TYPE_SET, + position * GST_MSECOND, GST_SEEK_TYPE_SET, + GST_CLOCK_TIME_NONE)) { + std::cerr << "Failed to set playback rate to " << playback_rate + << " (gst_element_seek failed)" << std::endl; + return; + } + + playback_rate_ = playback_rate; +} + +void GstAudioPlayer::SetLooping(bool is_looping) { + is_looping_ = is_looping; +} + +int64_t GstAudioPlayer::GetDuration() { + gint64 duration; + if (!gst_element_query_duration(gst_.playbin, GST_FORMAT_TIME, &duration)) { + return -1; + } + + return duration /= GST_MSECOND; +} + +int64_t GstAudioPlayer::GetCurrentPosition() { + gint64 position = 0; + if (!gst_element_query_position(gst_.playbin, GST_FORMAT_TIME, &position)) { + return -1; + } + + // TODO: We need to handle this code in the proper plase. + // The VideoPlayer plugin doesn't have a main loop, so EOS message + // received from GStreamer cannot be processed directly in a callback + // function. This is because the event channel message of playback complettion + // needs to be thrown in the main thread. + if (is_completed_) { + is_completed_ = false; + if (is_looping_) { + Play(); + } else { + stream_handler_->OnNotifyPlayCompleted(player_id_); + Stop(); + } + position = 0; + } + + return position / GST_MSECOND; +} + +void GstAudioPlayer::Release() { + is_playing_ = false; + is_initialized_ = false; + url_.clear(); + + GstState state; + gst_element_get_state(gst_.playbin, &state, NULL, GST_CLOCK_TIME_NONE); + if (state > GST_STATE_NULL) { + gst_bus_set_flushing(gst_.bus, TRUE); + gst_element_set_state(gst_.playbin, GST_STATE_NULL); + } +} + +void GstAudioPlayer::Dispose() { + if (!gst_.playbin) { + std::cerr << "Already disposed" << std::endl; + return; + } + is_playing_ = false; + is_initialized_ = false; + url_.clear(); + + if (gst_.bus) { + gst_bus_set_flushing(gst_.bus, TRUE); + gst_object_unref(GST_OBJECT(gst_.bus)); + gst_.bus = nullptr; + } + + if (gst_.source) { + gst_object_unref(GST_OBJECT(gst_.source)); + gst_.source = nullptr; + } + + if (gst_.panorama) { + gst_element_set_state(gst_.audiobin, GST_STATE_NULL); + gst_element_remove_pad(gst_.audiobin, gst_.panoramasinkpad); + gst_bin_remove(GST_BIN(gst_.audiobin), gst_.audiosink); + gst_bin_remove(GST_BIN(gst_.audiobin), gst_.panorama); + gst_.panorama = nullptr; + } + + gst_.playbin = nullptr; +} + +// static +GstBusSyncReply GstAudioPlayer::HandleGstMessage(GstBus* bus, + GstMessage* message, + gpointer user_data) { + auto* self = reinterpret_cast(user_data); + switch (GST_MESSAGE_TYPE(message)) { + case GST_MESSAGE_STATE_CHANGED: { + if (GST_MESSAGE_SRC(message) == GST_OBJECT(self->gst_.playbin)) { + GstState old_state, new_state; + gst_message_parse_state_changed(message, &old_state, &new_state, NULL); + if (new_state == GST_STATE_READY) { + if (gst_element_set_state(self->gst_.playbin, GST_STATE_PAUSED) == + GST_STATE_CHANGE_FAILURE) { + g_printerr("Unable to set the pipeline from GST_STATE_READY " + "to GST_STATE_PAUSED\n"); + } + } + } + break; + } + case GST_MESSAGE_EOS: + self->is_completed_ = true; + break; + case GST_MESSAGE_WARNING: { + gchar* debug; + GError* error; + gst_message_parse_warning(message, &error, &debug); + g_printerr("WARNING from element %s: %s\n", GST_OBJECT_NAME(message->src), + error->message); + g_printerr("Warning details: %s\n", debug); + g_free(debug); + g_error_free(error); + break; + } + case GST_MESSAGE_ERROR: { + gchar* debug; + GError* error; + gst_message_parse_error(message, &error, &debug); + g_printerr("ERROR from element %s: %s\n", GST_OBJECT_NAME(message->src), + error->message); + g_printerr("Error details: %s\n", debug); + g_free(debug); + g_error_free(error); + break; + } + default: + break; + } + + gst_message_unref(message); + + return GST_BUS_DROP; +} diff --git a/packages/audioplayers/elinux/gst_audio_player.h b/packages/audioplayers/elinux/gst_audio_player.h new file mode 100644 index 0000000..7495c05 --- /dev/null +++ b/packages/audioplayers/elinux/gst_audio_player.h @@ -0,0 +1,73 @@ +// Copyright 2024 Sony Group Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_GST_AUDIO_PLAYER_H_ +#define PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_GST_AUDIO_PLAYER_H_ + +#include + +#include +#include +#include +#include +#include + +#include "audio_player_stream_handler.h" + +class GstAudioPlayer { + public: + GstAudioPlayer(const std::string &player_id, + std::unique_ptr handler); + ~GstAudioPlayer(); + + static void GstLibraryLoad(); + static void GstLibraryUnload(); + + void Resume(); + void Play(); + void Pause(); + void Stop(); + void Seek(int64_t position); + void SetSourceUrl(std::string url); + void SetVolume(double volume); + void SetBalance(double balance); + void SetPlaybackRate(double playback_rate); + void SetLooping(bool is_looping); + int64_t GetDuration(); + int64_t GetCurrentPosition(); + void Release(); + void Dispose(); + + private: + struct GstAudioElements { + GstElement* playbin; + GstBus* bus; + GstElement* source; + GstElement* panorama; + GstElement* audiobin; + GstElement* audiosink; + GstPad* panoramasinkpad; + }; + + static GstBusSyncReply HandleGstMessage(GstBus* bus, GstMessage* message, + gpointer user_data); + static void SourceSetup(GstElement* playbin, + GstElement* source, + GstElement** p_src); + bool CreatePipeline(); + std::string ParseUri(const std::string& uri); + + GstAudioElements gst_; + const std::string player_id_; + std::string url_; + bool is_initialized_ = false; + bool is_playing_ = false; + bool is_looping_ = false; + double volume_ = 1.0; + double playback_rate_ = 1.0; + bool is_completed_ = false; + std::unique_ptr stream_handler_; +}; + +#endif // PACKAGES_AUDIOPLAYERS_AUDIOPLAYERS_ELINUX_GST_AUDIO_PLAYER_H_ diff --git a/packages/audioplayers/elinux/include/audioplayers_elinux/audioplayers_elinux_plugin.h b/packages/audioplayers/elinux/include/audioplayers_elinux/audioplayers_elinux_plugin.h new file mode 100644 index 0000000..5c3004d --- /dev/null +++ b/packages/audioplayers/elinux/include/audioplayers_elinux/audioplayers_elinux_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_AUDIOPLAYERS_ELINUX_PLUGIN_H_ +#define FLUTTER_PLUGIN_AUDIOPLAYERS_ELINUX_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void AudioplayersElinuxPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_AUDIOPLAYERS_ELINUX_PLUGIN_H_ diff --git a/packages/audioplayers/example/README.md b/packages/audioplayers/example/README.md new file mode 100644 index 0000000..0b8e397 --- /dev/null +++ b/packages/audioplayers/example/README.md @@ -0,0 +1,8 @@ +# audioplayers_example + +Demonstrates how to use the audioplayers plugin. + +## Getting Started + +For help getting started with Flutter for eLinux, view our online +[documentation](https://github.com/sony/flutter-elinux/wiki). \ No newline at end of file diff --git a/packages/audioplayers/example/assets/ambient_c_motion.mp3 b/packages/audioplayers/example/assets/ambient_c_motion.mp3 new file mode 100644 index 0000000..40a8516 Binary files /dev/null and b/packages/audioplayers/example/assets/ambient_c_motion.mp3 differ diff --git a/packages/audioplayers/example/assets/coins whitespace.wav b/packages/audioplayers/example/assets/coins whitespace.wav new file mode 100644 index 0000000..c0dc31c Binary files /dev/null and b/packages/audioplayers/example/assets/coins whitespace.wav differ diff --git a/packages/audioplayers/example/assets/coins.mp3 b/packages/audioplayers/example/assets/coins.mp3 new file mode 100644 index 0000000..e44d17d Binary files /dev/null and b/packages/audioplayers/example/assets/coins.mp3 differ diff --git a/packages/audioplayers/example/assets/coins.wav b/packages/audioplayers/example/assets/coins.wav new file mode 100644 index 0000000..c0dc31c Binary files /dev/null and b/packages/audioplayers/example/assets/coins.wav differ diff --git a/packages/audioplayers/example/assets/coins_no_extension b/packages/audioplayers/example/assets/coins_no_extension new file mode 100644 index 0000000..c0dc31c Binary files /dev/null and b/packages/audioplayers/example/assets/coins_no_extension differ diff --git a/packages/audioplayers/example/assets/coins_non_ascii_и.wav b/packages/audioplayers/example/assets/coins_non_ascii_и.wav new file mode 100644 index 0000000..c0dc31c Binary files /dev/null and b/packages/audioplayers/example/assets/coins_non_ascii_и.wav differ diff --git a/packages/audioplayers/example/assets/invalid.txt b/packages/audioplayers/example/assets/invalid.txt new file mode 100644 index 0000000..5cd09fd --- /dev/null +++ b/packages/audioplayers/example/assets/invalid.txt @@ -0,0 +1 @@ +This represents an invalid audio file. diff --git a/packages/audioplayers/example/assets/laser.wav b/packages/audioplayers/example/assets/laser.wav new file mode 100644 index 0000000..016326a Binary files /dev/null and b/packages/audioplayers/example/assets/laser.wav differ diff --git a/packages/audioplayers/example/assets/nasa_on_a_mission.mp3 b/packages/audioplayers/example/assets/nasa_on_a_mission.mp3 new file mode 100644 index 0000000..cce34a6 Binary files /dev/null and b/packages/audioplayers/example/assets/nasa_on_a_mission.mp3 differ diff --git a/packages/audioplayers/example/elinux/CMakeLists.txt b/packages/audioplayers/example/elinux/CMakeLists.txt new file mode 100644 index 0000000..d7a9fcd --- /dev/null +++ b/packages/audioplayers/example/elinux/CMakeLists.txt @@ -0,0 +1,103 @@ +cmake_minimum_required(VERSION 3.15) +# stop cmake from taking make from CMAKE_SYSROOT +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +project(runner LANGUAGES CXX) + +set(BINARY_NAME "audioplayers_example") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Basically we use this include when we got the following error: +# fatal error: 'bits/c++config.h' file not found +include_directories(SYSTEM ${FLUTTER_SYSTEM_INCLUDE_DIRECTORIES}) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) + +# Configure build options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Configure build option to target backend. +if (NOT FLUTTER_TARGET_BACKEND_TYPE) + set(FLUTTER_TARGET_BACKEND_TYPE "wayland" CACHE + STRING "Flutter target backend type" FORCE) + set_property(CACHE FLUTTER_TARGET_BACKEND_TYPE PROPERTY STRINGS + "wayland" "gbm" "eglstream" "x11") +endif() + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +install(FILES "${FLUTTER_EMBEDDER_LIBRARY}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/audioplayers/example/elinux/flutter/CMakeLists.txt b/packages/audioplayers/example/elinux/flutter/CMakeLists.txt new file mode 100644 index 0000000..f141a3c --- /dev/null +++ b/packages/audioplayers/example/elinux/flutter/CMakeLists.txt @@ -0,0 +1,108 @@ +cmake_minimum_required(VERSION 3.15) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_engine.so") +if(FLUTTER_TARGET_BACKEND_TYPE MATCHES "gbm") + set(FLUTTER_EMBEDDER_LIBRARY "${EPHEMERAL_DIR}/libflutter_elinux_gbm.so") +elseif(FLUTTER_TARGET_BACKEND_TYPE MATCHES "eglstream") + set(FLUTTER_EMBEDDER_LIBRARY "${EPHEMERAL_DIR}/libflutter_elinux_eglstream.so") +elseif(FLUTTER_TARGET_BACKEND_TYPE MATCHES "x11") + set(FLUTTER_EMBEDDER_LIBRARY "${EPHEMERAL_DIR}/libflutter_elinux_x11.so") +else() + set(FLUTTER_EMBEDDER_LIBRARY "${EPHEMERAL_DIR}/libflutter_elinux_wayland.so") +endif() + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_EMBEDDER_LIBRARY ${FLUTTER_EMBEDDER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/elinux/" PARENT_SCOPE) +set(AOT_LIBRARY "${EPHEMERAL_DIR}/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_plugin_registrar.h" + "flutter_messenger.h" + "flutter_texture_registrar.h" + "flutter_elinux.h" + "flutter_platform_views.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE "${FLUTTER_EMBEDDER_LIBRARY}") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list_prepend(CPP_WRAPPER_SOURCES_CORE "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list_prepend(CPP_WRAPPER_SOURCES_PLUGIN "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list_prepend(CPP_WRAPPER_SOURCES_APP "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + "${FLUTTER_EMBEDDER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/audioplayers/example/elinux/flutter/generated_plugins.cmake b/packages/audioplayers/example/elinux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..3a79db1 --- /dev/null +++ b/packages/audioplayers/example/elinux/flutter/generated_plugins.cmake @@ -0,0 +1,16 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_elinux +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/elinux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) diff --git a/packages/audioplayers/example/elinux/runner/CMakeLists.txt b/packages/audioplayers/example/elinux/runner/CMakeLists.txt new file mode 100644 index 0000000..d15d5ca --- /dev/null +++ b/packages/audioplayers/example/elinux/runner/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.15) +project(runner LANGUAGES CXX) + +if(FLUTTER_TARGET_BACKEND_TYPE MATCHES "gbm") + add_definitions(-DFLUTTER_TARGET_BACKEND_GBM) +elseif(FLUTTER_TARGET_BACKEND_TYPE MATCHES "eglstream") + add_definitions(-DFLUTTER_TARGET_BACKEND_EGLSTREAM) +elseif(FLUTTER_TARGET_BACKEND_TYPE MATCHES "x11") + add_definitions(-DFLUTTER_TARGET_BACKEND_X11) +else() + add_definitions(-DFLUTTER_TARGET_BACKEND_WAYLAND) +endif() + +add_executable(${BINARY_NAME} + "flutter_window.cc" + "main.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) +apply_standard_settings(${BINARY_NAME}) +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/audioplayers/example/elinux/runner/command_options.h b/packages/audioplayers/example/elinux/runner/command_options.h new file mode 100644 index 0000000..b0de931 --- /dev/null +++ b/packages/audioplayers/example/elinux/runner/command_options.h @@ -0,0 +1,402 @@ +// Copyright 2022 Sony Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef COMMAND_OPTIONS_ +#define COMMAND_OPTIONS_ + +#include +#include +#include +#include +#include +#include +#include + +namespace commandline { + +namespace { +constexpr char kOptionStyleNormal[] = "--"; +constexpr char kOptionStyleShort[] = "-"; +constexpr char kOptionValueForHelpMessage[] = "="; +} // namespace + +class Exception : public std::exception { + public: + Exception(const std::string& msg) : msg_(msg) {} + ~Exception() throw() {} + + const char* what() const throw() { return msg_.c_str(); } + + private: + std::string msg_; +}; + +class CommandOptions { + public: + CommandOptions() = default; + ~CommandOptions() = default; + + void AddWithoutValue(const std::string& name, + const std::string& short_name, + const std::string& description, + bool required) { + Add(name, short_name, description, "", + ReaderString(), required, false); + } + + void AddInt(const std::string& name, + const std::string& short_name, + const std::string& description, + const int& default_value, + bool required) { + Add(name, short_name, description, default_value, + ReaderInt(), required, true); + } + + void AddDouble(const std::string& name, + const std::string& short_name, + const std::string& description, + const double& default_value, + bool required) { + Add(name, short_name, description, default_value, + ReaderDouble(), required, true); + } + + void AddString(const std::string& name, + const std::string& short_name, + const std::string& description, + const std::string& default_value, + bool required) { + Add(name, short_name, description, default_value, + ReaderString(), required, true); + } + + template + void Add(const std::string& name, + const std::string& short_name, + const std::string& description, + const T default_value, + F reader = F(), + bool required = true, + bool required_value = true) { + if (options_.find(name) != options_.end()) { + std::cerr << "Already registered option: " << name << std::endl; + return; + } + + if (lut_short_options_.find(short_name) != lut_short_options_.end()) { + std::cerr << short_name << "is already registered" << std::endl; + return; + } + lut_short_options_[short_name] = name; + + options_[name] = std::make_unique>( + name, short_name, description, default_value, reader, required, + required_value); + + // register to show help message. + registration_order_options_.push_back(options_[name].get()); + } + + bool Exist(const std::string& name) { + auto itr = options_.find(name); + return itr != options_.end() && itr->second->HasValue(); + } + + template + const T& GetValue(const std::string& name) { + auto itr = options_.find(name); + if (itr == options_.end()) { + throw Exception("Not found: " + name); + } + + auto* option_value = dynamic_cast*>(itr->second.get()); + if (!option_value) { + throw Exception("Type mismatch: " + name); + } + return option_value->GetValue(); + } + + bool Parse(int argc, const char* const* argv) { + if (argc < 1) { + errors_.push_back("No options"); + return false; + } + + command_name_ = argv[0]; + for (auto i = 1; i < argc; i++) { + const std::string arg(argv[i]); + + // normal options: e.g. --bundle=/data/sample/bundle --fullscreen + if (arg.length() > 2 && + arg.substr(0, 2).compare(kOptionStyleNormal) == 0) { + const size_t option_value_len = arg.find("=") != std::string::npos + ? (arg.length() - arg.find("=")) + : 0; + const bool has_value = option_value_len != 0; + std::string option_name = + arg.substr(2, arg.length() - 2 - option_value_len); + + if (options_.find(option_name) == options_.end()) { + errors_.push_back("Not found option: " + option_name); + continue; + } + + if (!has_value && options_[option_name]->IsRequiredValue()) { + errors_.push_back(option_name + " requres an option value"); + continue; + } + + if (has_value && !options_[option_name]->IsRequiredValue()) { + errors_.push_back(option_name + " doesn't requres an option value"); + continue; + } + + if (has_value) { + SetOptionValue(option_name, arg.substr(arg.find("=") + 1)); + } else { + SetOption(option_name); + } + } + // short options: e.g. -f /foo/file.txt -h 640 -abc + else if (arg.length() > 1 && + arg.substr(0, 1).compare(kOptionStyleShort) == 0) { + for (size_t j = 1; j < arg.length(); j++) { + const std::string option_name{argv[i][j]}; + + if (lut_short_options_.find(option_name) == + lut_short_options_.end()) { + errors_.push_back("Not found short option: " + option_name); + break; + } + + if (j == arg.length() - 1 && + options_[lut_short_options_[option_name]]->IsRequiredValue()) { + if (i == argc - 1) { + errors_.push_back("Invalid format option: " + option_name); + break; + } + SetOptionValue(lut_short_options_[option_name], argv[++i]); + } else { + SetOption(lut_short_options_[option_name]); + } + } + } else { + errors_.push_back("Invalid format option: " + arg); + } + } + + for (size_t i = 0; i < registration_order_options_.size(); i++) { + if (registration_order_options_[i]->IsRequired() && + !registration_order_options_[i]->HasValue()) { + errors_.push_back( + std::string(registration_order_options_[i]->GetName()) + + " option is mandatory."); + } + } + + return errors_.size() == 0; + } + + std::string GetError() { return errors_.size() > 0 ? errors_[0] : ""; } + + std::vector& GetErrors() { return errors_; } + + std::string ShowHelp() { + std::ostringstream ostream; + + ostream << "Usage: " << command_name_ << " "; + for (size_t i = 0; i < registration_order_options_.size(); i++) { + if (registration_order_options_[i]->IsRequired()) { + ostream << registration_order_options_[i]->GetHelpShortMessage() << " "; + } + } + ostream << std::endl; + + ostream << "Global options:" << std::endl; + size_t max_name_len = 0; + for (size_t i = 0; i < registration_order_options_.size(); i++) { + max_name_len = std::max( + max_name_len, registration_order_options_[i]->GetName().length()); + } + + for (size_t i = 0; i < registration_order_options_.size(); i++) { + if (!registration_order_options_[i]->GetShortName().empty()) { + ostream << kOptionStyleShort + << registration_order_options_[i]->GetShortName() << ", "; + } else { + ostream << std::string(4, ' '); + } + + size_t index_adjust = 0; + constexpr int kSpacerNum = 10; + auto need_value = registration_order_options_[i]->IsRequiredValue(); + ostream << kOptionStyleNormal + << registration_order_options_[i]->GetName(); + if (need_value) { + ostream << kOptionValueForHelpMessage; + index_adjust += std::string(kOptionValueForHelpMessage).length(); + } + ostream << std::string( + max_name_len + kSpacerNum - index_adjust - + registration_order_options_[i]->GetName().length(), + ' '); + ostream << registration_order_options_[i]->GetDescription() << std::endl; + } + + return ostream.str(); + } + + private: + struct ReaderInt { + int operator()(const std::string& value) { return std::stoi(value); } + }; + + struct ReaderString { + std::string operator()(const std::string& value) { return value; } + }; + + struct ReaderDouble { + double operator()(const std::string& value) { return std::stod(value); } + }; + + class Option { + public: + Option(const std::string& name, + const std::string& short_name, + const std::string& description, + bool required, + bool required_value) + : name_(name), + short_name_(short_name), + description_(description), + is_required_(required), + is_required_value_(required_value), + value_set_(false){}; + virtual ~Option() = default; + + const std::string& GetName() const { return name_; }; + + const std::string& GetShortName() const { return short_name_; }; + + const std::string& GetDescription() const { return description_; }; + + const std::string GetHelpShortMessage() const { + std::string message = kOptionStyleNormal + name_; + if (is_required_value_) { + message += kOptionValueForHelpMessage; + } + return message; + } + + bool IsRequired() const { return is_required_; }; + + bool IsRequiredValue() const { return is_required_value_; }; + + void Set() { value_set_ = true; }; + + virtual bool SetValue(const std::string& value) = 0; + + virtual bool HasValue() const = 0; + + protected: + std::string name_; + std::string short_name_; + std::string description_; + bool is_required_; + bool is_required_value_; + bool value_set_; + }; + + template + class OptionValue : public Option { + public: + OptionValue(const std::string& name, + const std::string& short_name, + const std::string& description, + const T& default_value, + bool required, + bool required_value) + : Option(name, short_name, description, required, required_value), + default_value_(default_value), + value_(default_value){}; + virtual ~OptionValue() = default; + + bool SetValue(const std::string& value) { + value_ = Read(value); + value_set_ = true; + return true; + } + + bool HasValue() const { return value_set_; } + + const T& GetValue() const { return value_; } + + protected: + virtual T Read(const std::string& s) = 0; + + T default_value_; + T value_; + }; + + template + class OptionValueReader : public OptionValue { + public: + OptionValueReader(const std::string& name, + const std::string& short_name, + const std::string& description, + const T default_value, + F reader, + bool required, + bool required_value) + : OptionValue(name, + short_name, + description, + default_value, + required, + required_value), + reader_(reader) {} + ~OptionValueReader() = default; + + private: + T Read(const std::string& value) { return reader_(value); } + + F reader_; + }; + + bool SetOption(const std::string& name) { + auto itr = options_.find(name); + if (itr == options_.end()) { + errors_.push_back("Unknown option: " + name); + return false; + } + + itr->second->Set(); + return true; + } + + bool SetOptionValue(const std::string& name, const std::string& value) { + auto itr = options_.find(name); + if (itr == options_.end()) { + errors_.push_back("Unknown option: " + name); + return false; + } + + if (!itr->second->SetValue(value)) { + errors_.push_back("Invalid option value: " + name + " = " + value); + return false; + } + return true; + } + + std::string command_name_; + std::unordered_map> options_; + std::unordered_map lut_short_options_; + std::vector registration_order_options_; + std::vector errors_; +}; + +} // namespace commandline + +#endif // COMMAND_OPTIONS_ diff --git a/packages/audioplayers/example/elinux/runner/flutter_embedder_options.h b/packages/audioplayers/example/elinux/runner/flutter_embedder_options.h new file mode 100644 index 0000000..41d0bd1 --- /dev/null +++ b/packages/audioplayers/example/elinux/runner/flutter_embedder_options.h @@ -0,0 +1,203 @@ +// Copyright 2021 Sony Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_EMBEDDER_OPTIONS_ +#define FLUTTER_EMBEDDER_OPTIONS_ + +#include + +#include + +#include "command_options.h" + +class FlutterEmbedderOptions { + public: + FlutterEmbedderOptions() { + options_.AddString("bundle", "b", "Path to Flutter project bundle", + "./bundle", true); + options_.AddWithoutValue("no-cursor", "n", "No mouse cursor/pointer", + false); + options_.AddInt("rotation", "r", + "Window rotation(degree) [0(default)|90|180|270]", 0, + false); + options_.AddDouble("text-scaling-factor", "x", "Text scaling factor", 1.0, + false); + options_.AddWithoutValue("enable-high-contrast", "i", + "Request that UI be rendered with darker colors.", + false); + options_.AddDouble("force-scale-factor", "s", + "Force a scale factor instead using default value", 1.0, + false); + options_.AddWithoutValue( + "async-vblank", "v", + "Don't sync to compositor redraw/vblank (eglSwapInterval 0)", false); + +#if defined(FLUTTER_TARGET_BACKEND_GBM) || \ + defined(FLUTTER_TARGET_BACKEND_EGLSTREAM) + // no more options. +#elif defined(FLUTTER_TARGET_BACKEND_X11) + options_.AddString("title", "t", "Window title", "Flutter", false); + options_.AddWithoutValue("fullscreen", "f", "Always full-screen display", + false); + options_.AddInt("width", "w", "Window width", 1280, false); + options_.AddInt("height", "h", "Window height", 720, false); +#else // FLUTTER_TARGET_BACKEND_WAYLAND + options_.AddString("title", "t", "Window title", "Flutter", false); + options_.AddString("app-id", "a", "XDG App ID", "dev.flutter.elinux", + false); + options_.AddWithoutValue("onscreen-keyboard", "k", + "Enable on-screen keyboard", false); + options_.AddWithoutValue("window-decoration", "d", + "Enable window decorations", false); + options_.AddWithoutValue("fullscreen", "f", "Always full-screen display", + false); + options_.AddInt("width", "w", "Window width", 1280, false); + options_.AddInt("height", "h", "Window height", 720, false); +#endif + } + ~FlutterEmbedderOptions() = default; + + bool Parse(int argc, char** argv) { + if (!options_.Parse(argc, argv)) { + std::cerr << options_.GetError() << std::endl; + std::cout << options_.ShowHelp(); + return false; + } + + bundle_path_ = options_.GetValue("bundle"); + use_mouse_cursor_ = !options_.Exist("no-cursor"); + if (options_.Exist("rotation")) { + switch (options_.GetValue("rotation")) { + case 90: + window_view_rotation_ = + flutter::FlutterViewController::ViewRotation::kRotation_90; + break; + case 180: + window_view_rotation_ = + flutter::FlutterViewController::ViewRotation::kRotation_180; + break; + case 270: + window_view_rotation_ = + flutter::FlutterViewController::ViewRotation::kRotation_270; + break; + default: + window_view_rotation_ = + flutter::FlutterViewController::ViewRotation::kRotation_0; + break; + } + } + + text_scale_factor_ = options_.GetValue("text-scaling-factor"); + enable_high_contrast_ = options_.Exist("enable-high-contrast"); + + if (options_.Exist("force-scale-factor")) { + is_force_scale_factor_ = true; + scale_factor_ = options_.GetValue("force-scale-factor"); + } else { + is_force_scale_factor_ = false; + scale_factor_ = 1.0; + } + + enable_vsync_ = !options_.Exist("async-vblank"); + +#if defined(FLUTTER_TARGET_BACKEND_GBM) || \ + defined(FLUTTER_TARGET_BACKEND_EGLSTREAM) + use_onscreen_keyboard_ = false; + use_window_decoration_ = false; + window_view_mode_ = flutter::FlutterViewController::ViewMode::kFullscreen; +#elif defined(FLUTTER_TARGET_BACKEND_X11) + use_onscreen_keyboard_ = false; + use_window_decoration_ = false; + window_title_ = options_.GetValue("title"); + window_view_mode_ = + options_.Exist("fullscreen") + ? flutter::FlutterViewController::ViewMode::kFullscreen + : flutter::FlutterViewController::ViewMode::kNormal; + window_width_ = options_.GetValue("width"); + window_height_ = options_.GetValue("height"); +#else // FLUTTER_TARGET_BACKEND_WAYLAND + window_title_ = options_.GetValue("title"); + window_app_id_ = options_.GetValue("app-id"); + use_onscreen_keyboard_ = options_.Exist("onscreen-keyboard"); + use_window_decoration_ = options_.Exist("window-decoration"); + window_view_mode_ = + options_.Exist("fullscreen") + ? flutter::FlutterViewController::ViewMode::kFullscreen + : flutter::FlutterViewController::ViewMode::kNormal; + window_width_ = options_.GetValue("width"); + window_height_ = options_.GetValue("height"); +#endif + + return true; + } + + std::string BundlePath() const { + return bundle_path_; + } + std::string WindowTitle() const { + return window_title_; + } + std::string WindowAppID() const { + return window_app_id_; + } + bool IsUseMouseCursor() const { + return use_mouse_cursor_; + } + bool IsUseOnscreenKeyboard() const { + return use_onscreen_keyboard_; + } + bool IsUseWindowDecoraation() const { + return use_window_decoration_; + } + flutter::FlutterViewController::ViewMode WindowViewMode() const { + return window_view_mode_; + } + int WindowWidth() const { + return window_width_; + } + int WindowHeight() const { + return window_height_; + } + flutter::FlutterViewController::ViewRotation WindowRotation() const { + return window_view_rotation_; + } + double TextScaleFactor() const { + return text_scale_factor_; + } + bool EnableHighContrast() const { + return enable_high_contrast_; + } + bool IsForceScaleFactor() const { + return is_force_scale_factor_; + } + double ScaleFactor() const { + return scale_factor_; + } + bool EnableVsync() const { + return enable_vsync_; + } + + private: + commandline::CommandOptions options_; + + std::string bundle_path_; + std::string window_title_; + std::string window_app_id_; + bool use_mouse_cursor_ = true; + bool use_onscreen_keyboard_ = false; + bool use_window_decoration_ = false; + flutter::FlutterViewController::ViewMode window_view_mode_ = + flutter::FlutterViewController::ViewMode::kNormal; + int window_width_ = 1280; + int window_height_ = 720; + flutter::FlutterViewController::ViewRotation window_view_rotation_ = + flutter::FlutterViewController::ViewRotation::kRotation_0; + bool is_force_scale_factor_; + double scale_factor_; + double text_scale_factor_; + bool enable_high_contrast_; + bool enable_vsync_; +}; + +#endif // FLUTTER_EMBEDDER_OPTIONS_ diff --git a/packages/audioplayers/example/elinux/runner/flutter_window.cc b/packages/audioplayers/example/elinux/runner/flutter_window.cc new file mode 100644 index 0000000..0c5b639 --- /dev/null +++ b/packages/audioplayers/example/elinux/runner/flutter_window.cc @@ -0,0 +1,79 @@ +// Copyright 2021 Sony Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter_window.h" + +#include +#include +#include +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow( + const flutter::FlutterViewController::ViewProperties view_properties, + const flutter::DartProject project) + : view_properties_(view_properties), project_(project) {} + +bool FlutterWindow::OnCreate() { + flutter_view_controller_ = std::make_unique( + view_properties_, project_); + + // Ensure that basic setup of the controller was successful. + if (!flutter_view_controller_->engine() || + !flutter_view_controller_->view()) { + return false; + } + + // Register Flutter plugins. + RegisterPlugins(flutter_view_controller_->engine()); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_view_controller_) { + flutter_view_controller_ = nullptr; + } +} + +void FlutterWindow::Run() { + // Main loop. + auto next_flutter_event_time = + std::chrono::steady_clock::time_point::clock::now(); + while (flutter_view_controller_->view()->DispatchEvent()) { + // Wait until the next event. + { + auto wait_duration = + std::max(std::chrono::nanoseconds(0), + next_flutter_event_time - + std::chrono::steady_clock::time_point::clock::now()); + std::this_thread::sleep_for( + std::chrono::duration_cast(wait_duration)); + } + + // Processes any pending events in the Flutter engine, and returns the + // number of nanoseconds until the next scheduled event (or max, if none). + auto wait_duration = flutter_view_controller_->engine()->ProcessMessages(); + { + auto next_event_time = std::chrono::steady_clock::time_point::max(); + if (wait_duration != std::chrono::nanoseconds::max()) { + next_event_time = + std::min(next_event_time, + std::chrono::steady_clock::time_point::clock::now() + + wait_duration); + } else { + // Wait for the next frame if no events. + auto frame_rate = flutter_view_controller_->view()->GetFrameRate(); + next_event_time = std::min( + next_event_time, + std::chrono::steady_clock::time_point::clock::now() + + std::chrono::milliseconds( + static_cast(std::trunc(1000000.0 / frame_rate)))); + } + next_flutter_event_time = + std::max(next_flutter_event_time, next_event_time); + } + } +} diff --git a/packages/audioplayers/example/elinux/runner/flutter_window.h b/packages/audioplayers/example/elinux/runner/flutter_window.h new file mode 100644 index 0000000..20b9cb8 --- /dev/null +++ b/packages/audioplayers/example/elinux/runner/flutter_window.h @@ -0,0 +1,34 @@ +// Copyright 2021 Sony Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_WINDOW_ +#define FLUTTER_WINDOW_ + +#include +#include + +#include + +class FlutterWindow { + public: + explicit FlutterWindow( + const flutter::FlutterViewController::ViewProperties view_properties, + const flutter::DartProject project); + ~FlutterWindow() = default; + + // Prevent copying. + FlutterWindow(FlutterWindow const&) = delete; + FlutterWindow& operator=(FlutterWindow const&) = delete; + + bool OnCreate(); + void OnDestroy(); + void Run(); + + private: + flutter::FlutterViewController::ViewProperties view_properties_; + flutter::DartProject project_; + std::unique_ptr flutter_view_controller_; +}; + +#endif // FLUTTER_WINDOW_ diff --git a/packages/audioplayers/example/elinux/runner/main.cc b/packages/audioplayers/example/elinux/runner/main.cc new file mode 100644 index 0000000..579daee --- /dev/null +++ b/packages/audioplayers/example/elinux/runner/main.cc @@ -0,0 +1,53 @@ +// Copyright 2021 Sony Corporation. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include +#include + +#include +#include +#include + +#include "flutter_embedder_options.h" +#include "flutter_window.h" + +int main(int argc, char** argv) { + FlutterEmbedderOptions options; + if (!options.Parse(argc, argv)) { + return 0; + } + + // Creates the Flutter project. + const auto bundle_path = options.BundlePath(); + const std::wstring fl_path(bundle_path.begin(), bundle_path.end()); + flutter::DartProject project(fl_path); + auto command_line_arguments = std::vector(); + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + flutter::FlutterViewController::ViewProperties view_properties = {}; + view_properties.width = options.WindowWidth(); + view_properties.height = options.WindowHeight(); + view_properties.view_mode = options.WindowViewMode(); + view_properties.view_rotation = options.WindowRotation(); + view_properties.title = options.WindowTitle(); + view_properties.app_id = options.WindowAppID(); + view_properties.use_mouse_cursor = options.IsUseMouseCursor(); + view_properties.use_onscreen_keyboard = options.IsUseOnscreenKeyboard(); + view_properties.use_window_decoration = options.IsUseWindowDecoraation(); + view_properties.text_scale_factor = options.TextScaleFactor(); + view_properties.enable_high_contrast = options.EnableHighContrast(); + view_properties.force_scale_factor = options.IsForceScaleFactor(); + view_properties.scale_factor = options.ScaleFactor(); + view_properties.enable_vsync = options.EnableVsync(); + + // The Flutter instance hosted by this window. + FlutterWindow window(view_properties, project); + if (!window.OnCreate()) { + return 0; + } + window.Run(); + window.OnDestroy(); + + return 0; +} diff --git a/packages/audioplayers/example/lib/components/btn.dart b/packages/audioplayers/example/lib/components/btn.dart new file mode 100644 index 0000000..238c4d4 --- /dev/null +++ b/packages/audioplayers/example/lib/components/btn.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class Btn extends StatelessWidget { + final String txt; + final VoidCallback onPressed; + + const Btn({ + required this.txt, + required this.onPressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: ElevatedButton( + style: ElevatedButton.styleFrom(minimumSize: const Size(48, 36)), + onPressed: onPressed, + child: Text(txt), + ), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/cbx.dart b/packages/audioplayers/example/lib/components/cbx.dart new file mode 100644 index 0000000..65674a2 --- /dev/null +++ b/packages/audioplayers/example/lib/components/cbx.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class Cbx extends StatelessWidget { + final String label; + final bool value; + final void Function({required bool? value}) update; + + const Cbx( + this.label, + this.update, { + required this.value, + super.key, + }); + + @override + Widget build(BuildContext context) { + return CheckboxListTile( + title: Text(label), + value: value, + onChanged: (v) => update(value: v), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/dlg.dart b/packages/audioplayers/example/lib/components/dlg.dart new file mode 100644 index 0000000..71a616a --- /dev/null +++ b/packages/audioplayers/example/lib/components/dlg.dart @@ -0,0 +1,48 @@ +import 'package:audioplayers_elinux_example/components/btn.dart'; +import 'package:flutter/material.dart'; + +class SimpleDlg extends StatelessWidget { + final String message; + final String action; + + const SimpleDlg({ + required this.message, + required this.action, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Dlg( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message), + Btn( + txt: action, + onPressed: Navigator.of(context).pop, + ), + ], + ), + ); + } +} + +class Dlg extends StatelessWidget { + final Widget child; + + const Dlg({ + required this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: child, + ), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/drop_down.dart b/packages/audioplayers/example/lib/components/drop_down.dart new file mode 100644 index 0000000..97f1f5e --- /dev/null +++ b/packages/audioplayers/example/lib/components/drop_down.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class LabeledDropDown extends StatelessWidget { + final String label; + final Map options; + final T selected; + final void Function(T?) onChange; + + const LabeledDropDown({ + required this.label, + required this.options, + required this.selected, + required this.onChange, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(label), + trailing: CustomDropDown( + options: options, + selected: selected, + onChange: onChange, + ), + ); + } +} + +class CustomDropDown extends StatelessWidget { + final Map options; + final T selected; + final void Function(T?) onChange; + final bool isExpanded; + + const CustomDropDown({ + required this.options, + required this.selected, + required this.onChange, + this.isExpanded = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + return DropdownButton( + isExpanded: isExpanded, + value: selected, + onChanged: onChange, + items: options.entries + .map>( + (entry) => DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ), + ) + .toList(), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/list_tile.dart b/packages/audioplayers/example/lib/components/list_tile.dart new file mode 100644 index 0000000..b7dc520 --- /dev/null +++ b/packages/audioplayers/example/lib/components/list_tile.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class WrappedListTile extends StatelessWidget { + final List children; + final Widget? leading; + final Widget? trailing; + + const WrappedListTile({ + required this.children, + this.leading, + this.trailing, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Wrap( + alignment: WrapAlignment.end, + children: children, + ), + leading: leading, + trailing: trailing, + ); + } +} diff --git a/packages/audioplayers/example/lib/components/pad.dart b/packages/audioplayers/example/lib/components/pad.dart new file mode 100644 index 0000000..2bc3648 --- /dev/null +++ b/packages/audioplayers/example/lib/components/pad.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class Pad extends StatelessWidget { + final double width; + final double height; + + const Pad({super.key, this.width = 0, this.height = 0}); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + ); + } +} diff --git a/packages/audioplayers/example/lib/components/player_widget.dart b/packages/audioplayers/example/lib/components/player_widget.dart new file mode 100644 index 0000000..0994c05 --- /dev/null +++ b/packages/audioplayers/example/lib/components/player_widget.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; + +// This code is also used in the example.md. Please keep it up to date. +class PlayerWidget extends StatefulWidget { + final AudioPlayer player; + + const PlayerWidget({ + required this.player, + super.key, + }); + + @override + State createState() { + return _PlayerWidgetState(); + } +} + +class _PlayerWidgetState extends State { + PlayerState? _playerState; + Duration? _duration; + Duration? _position; + + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playerCompleteSubscription; + StreamSubscription? _playerStateChangeSubscription; + + bool get _isPlaying => _playerState == PlayerState.playing; + + bool get _isPaused => _playerState == PlayerState.paused; + + String get _durationText => _duration?.toString().split('.').first ?? ''; + + String get _positionText => _position?.toString().split('.').first ?? ''; + + AudioPlayer get player => widget.player; + + @override + void initState() { + super.initState(); + // Use initial values from player + _playerState = player.state; + player.getDuration().then( + (value) => setState(() { + _duration = value; + }), + ); + player.getCurrentPosition().then( + (value) => setState(() { + _position = value; + }), + ); + _initStreams(); + } + + @override + void setState(VoidCallback fn) { + // Subscriptions only can be closed asynchronously, + // therefore events can occur after widget has been disposed. + if (mounted) { + super.setState(fn); + } + } + + @override + void dispose() { + _durationSubscription?.cancel(); + _positionSubscription?.cancel(); + _playerCompleteSubscription?.cancel(); + _playerStateChangeSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).primaryColor; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: const Key('play_button'), + onPressed: _isPlaying ? null : _play, + iconSize: 48.0, + icon: const Icon(Icons.play_arrow), + color: color, + ), + IconButton( + key: const Key('pause_button'), + onPressed: _isPlaying ? _pause : null, + iconSize: 48.0, + icon: const Icon(Icons.pause), + color: color, + ), + IconButton( + key: const Key('stop_button'), + onPressed: _isPlaying || _isPaused ? _stop : null, + iconSize: 48.0, + icon: const Icon(Icons.stop), + color: color, + ), + ], + ), + Slider( + onChanged: (value) { + final duration = _duration; + if (duration == null) { + return; + } + final position = value * duration.inMilliseconds; + player.seek(Duration(milliseconds: position.round())); + }, + value: (_position != null && + _duration != null && + _position!.inMilliseconds > 0 && + _position!.inMilliseconds < _duration!.inMilliseconds) + ? _position!.inMilliseconds / _duration!.inMilliseconds + : 0.0, + ), + Text( + _position != null + ? '$_positionText / $_durationText' + : _duration != null + ? _durationText + : '', + style: const TextStyle(fontSize: 16.0), + ), + ], + ); + } + + void _initStreams() { + _durationSubscription = player.onDurationChanged.listen((duration) { + setState(() => _duration = duration); + }); + + _positionSubscription = player.onPositionChanged.listen( + (p) => setState(() => _position = p), + ); + + _playerCompleteSubscription = player.onPlayerComplete.listen((event) { + setState(() { + _playerState = PlayerState.stopped; + _position = Duration.zero; + }); + }); + + _playerStateChangeSubscription = + player.onPlayerStateChanged.listen((state) { + setState(() { + _playerState = state; + }); + }); + } + + Future _play() async { + await player.resume(); + setState(() => _playerState = PlayerState.playing); + } + + Future _pause() async { + await player.pause(); + setState(() => _playerState = PlayerState.paused); + } + + Future _stop() async { + await player.stop(); + setState(() { + _playerState = PlayerState.stopped; + _position = Duration.zero; + }); + } +} diff --git a/packages/audioplayers/example/lib/components/properties_widget.dart b/packages/audioplayers/example/lib/components/properties_widget.dart new file mode 100644 index 0000000..8138769 --- /dev/null +++ b/packages/audioplayers/example/lib/components/properties_widget.dart @@ -0,0 +1,104 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/utils.dart'; +import 'package:flutter/material.dart'; + +class PropertiesWidget extends StatefulWidget { + final AudioPlayer player; + + const PropertiesWidget({ + required this.player, + super.key, + }); + + @override + State createState() => _PropertiesWidgetState(); +} + +class _PropertiesWidgetState extends State { + Future refresh() async { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: const Text('Properties'), + trailing: ElevatedButton.icon( + icon: const Icon(Icons.refresh), + key: const Key('refreshButton'), + label: const Text('Refresh'), + onPressed: refresh, + ), + ), + ListTile( + title: FutureBuilder( + future: widget.player.getDuration(), + builder: (context, snap) { + return Text( + snap.data?.toString() ?? '-', + key: const Key('durationText'), + ); + }, + ), + subtitle: const Text('Duration'), + leading: const Icon(Icons.timelapse), + ), + ListTile( + title: FutureBuilder( + future: widget.player.getCurrentPosition(), + builder: (context, snap) { + return Text( + snap.data?.toString() ?? '-', + key: const Key('positionText'), + ); + }, + ), + subtitle: const Text('Position'), + leading: const Icon(Icons.timer), + ), + ListTile( + title: Text( + widget.player.state.toString(), + key: const Key('playerStateText'), + ), + subtitle: const Text('State'), + leading: Icon(widget.player.state.getIcon()), + ), + ListTile( + title: Text( + widget.player.source?.toString() ?? '-', + key: const Key('sourceText'), + ), + subtitle: const Text('Source'), + leading: const Icon(Icons.audio_file), + ), + ListTile( + title: Text( + widget.player.volume.toString(), + key: const Key('volumeText'), + ), + subtitle: const Text('Volume'), + leading: const Icon(Icons.volume_up), + ), + ListTile( + title: Text( + widget.player.balance.toString(), + key: const Key('balanceText'), + ), + subtitle: const Text('Balance'), + leading: const Icon(Icons.balance), + ), + ListTile( + title: Text( + widget.player.playbackRate.toString(), + key: const Key('playbackRateText'), + ), + subtitle: const Text('Playback Rate'), + leading: const Icon(Icons.speed), + ), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/components/stream_widget.dart b/packages/audioplayers/example/lib/components/stream_widget.dart new file mode 100644 index 0000000..688566e --- /dev/null +++ b/packages/audioplayers/example/lib/components/stream_widget.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/utils.dart'; +import 'package:flutter/material.dart'; + +class StreamWidget extends StatefulWidget { + final AudioPlayer player; + + const StreamWidget({ + required this.player, + super.key, + }); + + @override + State createState() => _StreamWidgetState(); +} + +class _StreamWidgetState extends State { + Duration? streamDuration; + Duration? streamPosition; + PlayerState? streamState; + late List streams; + + AudioPlayer get player => widget.player; + + @override + void initState() { + super.initState(); + // Use initial values from player + streamState = player.state; + player.getDuration().then((it) => setState(() => streamDuration = it)); + player.getCurrentPosition().then( + (it) => setState(() => streamPosition = it), + ); + + streams = [ + player.onDurationChanged + .listen((it) => setState(() => streamDuration = it)), + player.onPlayerStateChanged + .listen((it) => setState(() => streamState = it)), + player.onPositionChanged + .listen((it) => setState(() => streamPosition = it)), + ]; + } + + @override + void dispose() { + super.dispose(); + streams.forEach((it) => it.cancel()); + } + + @override + void setState(VoidCallback fn) { + // Subscriptions only can be closed asynchronously, + // therefore events can occur after widget has been disposed. + if (mounted) { + super.setState(fn); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const ListTile(title: Text('Streams')), + ListTile( + title: Text( + streamDuration?.toString() ?? '-', + key: const Key('onDurationText'), + ), + subtitle: const Text('Duration Stream'), + leading: const Icon(Icons.timelapse), + ), + ListTile( + title: Text( + streamPosition?.toString() ?? '-', + key: const Key('onPositionText'), + ), + subtitle: const Text('Position Stream'), + leading: const Icon(Icons.timer), + ), + ListTile( + title: Text( + streamState?.toString() ?? '-', + key: const Key('onStateText'), + ), + subtitle: const Text('State Stream'), + leading: Icon(streamState?.getIcon() ?? Icons.stop), + ), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/components/tab_content.dart b/packages/audioplayers/example/lib/components/tab_content.dart new file mode 100644 index 0000000..abbad29 --- /dev/null +++ b/packages/audioplayers/example/lib/components/tab_content.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class TabContent extends StatelessWidget { + final List children; + + const TabContent({ + required this.children, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + alignment: Alignment.topCenter, + child: SingleChildScrollView( + controller: ScrollController(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: children, + ), + ), + ), + ), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/tabs.dart b/packages/audioplayers/example/lib/components/tabs.dart new file mode 100644 index 0000000..0cd5c63 --- /dev/null +++ b/packages/audioplayers/example/lib/components/tabs.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class Tabs extends StatelessWidget { + final List tabs; + + const Tabs({ + required this.tabs, + super.key, + }); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: tabs.length, + child: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TabBar( + labelColor: Colors.black, + tabs: tabs + .map( + (tData) => Tab( + key: tData.key != null ? Key(tData.key!) : null, + text: tData.label, + ), + ) + .toList(), + ), + Expanded( + child: TabBarView( + children: tabs.map((tab) => tab.content).toList(), + ), + ), + ], + ), + ), + ); + } +} + +class TabData { + final String? key; + final String label; + final Widget content; + + TabData({ + required this.label, + required this.content, + this.key, + }); +} diff --git a/packages/audioplayers/example/lib/components/tgl.dart b/packages/audioplayers/example/lib/components/tgl.dart new file mode 100644 index 0000000..d28a1df --- /dev/null +++ b/packages/audioplayers/example/lib/components/tgl.dart @@ -0,0 +1,61 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class Tgl extends StatelessWidget { + final Map options; + final int selected; + final void Function(int) onChange; + + const Tgl({ + required this.options, + required this.selected, + required this.onChange, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ToggleButtons( + isSelected: options.entries + .mapIndexed((index, element) => index == selected) + .toList(), + onPressed: onChange, + borderRadius: const BorderRadius.all(Radius.circular(8)), + selectedBorderColor: Theme.of(context).primaryColor, + children: options.entries + .map( + (entry) => Padding( + padding: const EdgeInsets.all(8), + child: Text( + entry.value, + key: Key(entry.key), + ), + ), + ) + .toList(), + ); + } +} + +class EnumTgl extends StatelessWidget { + final Map options; + final T selected; + final void Function(T) onChange; + + const EnumTgl({ + required this.options, + required this.selected, + required this.onChange, + super.key, + }); + + @override + Widget build(BuildContext context) { + final optionValues = options.values.toList(); + return Tgl( + options: options.map((key, value) => MapEntry(key, value.name)), + selected: optionValues.indexOf(selected), + onChange: (it) => onChange(optionValues[it]), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/txt.dart b/packages/audioplayers/example/lib/components/txt.dart new file mode 100644 index 0000000..177251f --- /dev/null +++ b/packages/audioplayers/example/lib/components/txt.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class TxtBox extends StatefulWidget { + final String value; + final void Function(String) onChange; + + const TxtBox({ + required this.value, + required this.onChange, + super.key, + }); + + @override + State createState() => _TxtBoxState(); +} + +class _TxtBoxState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: widget.value, + )..addListener(() => widget.onChange(_controller.text)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField(controller: _controller); + } +} diff --git a/packages/audioplayers/example/lib/main.dart b/packages/audioplayers/example/lib/main.dart new file mode 100644 index 0000000..8b40527 --- /dev/null +++ b/packages/audioplayers/example/lib/main.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/components/tabs.dart'; +import 'package:audioplayers_elinux_example/components/tgl.dart'; +import 'package:audioplayers_elinux_example/tabs/audio_context.dart'; +import 'package:audioplayers_elinux_example/tabs/controls.dart'; +import 'package:audioplayers_elinux_example/tabs/logger.dart'; +import 'package:audioplayers_elinux_example/tabs/sources.dart'; +import 'package:audioplayers_elinux_example/tabs/streams.dart'; +import 'package:audioplayers_elinux_example/utils.dart'; +import 'package:flutter/material.dart'; + +const defaultPlayerCount = 4; + +typedef OnError = void Function(Exception exception); + +/// The app is deployed at: https://bluefireteam.github.io/audioplayers/ +void main() { + runApp(const MaterialApp(home: _ExampleApp())); +} + +class _ExampleApp extends StatefulWidget { + const _ExampleApp(); + + @override + _ExampleAppState createState() => _ExampleAppState(); +} + +class _ExampleAppState extends State<_ExampleApp> { + List audioPlayers = List.generate( + defaultPlayerCount, + (_) => AudioPlayer()..setReleaseMode(ReleaseMode.stop), + ); + int selectedPlayerIdx = 0; + + AudioPlayer get selectedAudioPlayer => audioPlayers[selectedPlayerIdx]; + List streams = []; + + @override + void initState() { + super.initState(); + audioPlayers.asMap().forEach((index, player) { + streams.add( + player.onPlayerStateChanged.listen( + (it) { + switch (it) { + case PlayerState.stopped: + toast( + 'Player stopped!', + textKey: Key('toast-player-stopped-$index'), + ); + break; + case PlayerState.completed: + toast( + 'Player complete!', + textKey: Key('toast-player-complete-$index'), + ); + break; + default: + break; + } + }, + ), + ); + streams.add( + player.onSeekComplete.listen( + (it) => toast( + 'Seek complete!', + textKey: Key('toast-seek-complete-$index'), + ), + ), + ); + }); + } + + @override + void dispose() { + streams.forEach((it) => it.cancel()); + super.dispose(); + } + + void _handleAction(PopupAction value) { + switch (value) { + case PopupAction.add: + setState(() { + audioPlayers.add(AudioPlayer()..setReleaseMode(ReleaseMode.stop)); + }); + break; + case PopupAction.remove: + setState(() { + if (audioPlayers.isNotEmpty) { + selectedAudioPlayer.dispose(); + audioPlayers.removeAt(selectedPlayerIdx); + } + // Adjust index to be in valid range + if (audioPlayers.isEmpty) { + selectedPlayerIdx = 0; + } else if (selectedPlayerIdx >= audioPlayers.length) { + selectedPlayerIdx = audioPlayers.length - 1; + } + }); + break; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('AudioPlayers example'), + actions: [ + PopupMenuButton( + onSelected: _handleAction, + itemBuilder: (BuildContext context) { + return PopupAction.values.map((PopupAction choice) { + return PopupMenuItem( + value: choice, + child: Text( + choice == PopupAction.add + ? 'Add player' + : 'Remove selected player', + ), + ); + }).toList(); + }, + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Tgl( + key: const Key('playerTgl'), + options: [for (var i = 1; i <= audioPlayers.length; i++) i] + .asMap() + .map((key, val) => MapEntry('player-$key', 'P$val')), + selected: selectedPlayerIdx, + onChange: (v) => setState(() => selectedPlayerIdx = v), + ), + ), + ), + ), + Expanded( + child: audioPlayers.isEmpty + ? const Text('No AudioPlayer available!') + : IndexedStack( + index: selectedPlayerIdx, + children: audioPlayers + .map( + (player) => Tabs( + key: GlobalObjectKey(player), + tabs: [ + TabData( + key: 'sourcesTab', + label: 'Src', + content: SourcesTab( + player: player, + ), + ), + TabData( + key: 'controlsTab', + label: 'Ctrl', + content: ControlsTab( + player: player, + ), + ), + TabData( + key: 'streamsTab', + label: 'Stream', + content: StreamsTab( + player: player, + ), + ), + TabData( + key: 'audioContextTab', + label: 'Ctx', + content: AudioContextTab( + player: player, + ), + ), + TabData( + key: 'loggerTab', + label: 'Log', + content: LoggerTab( + player: player, + ), + ), + ], + ), + ) + .toList(), + ), + ), + ], + ), + ); + } +} + +enum PopupAction { + add, + remove, +} diff --git a/packages/audioplayers/example/lib/tabs/audio_context.dart b/packages/audioplayers/example/lib/tabs/audio_context.dart new file mode 100644 index 0000000..ee054f8 --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/audio_context.dart @@ -0,0 +1,246 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/components/cbx.dart'; +import 'package:audioplayers_elinux_example/components/drop_down.dart'; +import 'package:audioplayers_elinux_example/components/tab_content.dart'; +import 'package:audioplayers_elinux_example/components/tabs.dart'; +import 'package:audioplayers_elinux_example/utils.dart'; +import 'package:flutter/material.dart'; + +class AudioContextTab extends StatefulWidget { + final AudioPlayer player; + + const AudioContextTab({ + required this.player, + super.key, + }); + + @override + AudioContextTabState createState() => AudioContextTabState(); +} + +class AudioContextTabState extends State + with AutomaticKeepAliveClientMixin { + static GlobalAudioScope get _global => AudioPlayer.global; + + AudioPlayer get player => widget.player; + + /// Set config for all platforms + AudioContextConfig audioContextConfig = AudioContextConfig(); + + /// Set config for each platform individually + AudioContext audioContext = AudioContext(); + + @override + Widget build(BuildContext context) { + super.build(context); + return Column( + children: [ + const ListTile(title: Text('Audio Context')), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.undo), + label: const Text('Reset'), + onPressed: () => updateConfig(AudioContextConfig()), + ), + ElevatedButton.icon( + icon: const Icon(Icons.public), + label: const Text('Global'), + onPressed: () => _global.setAudioContext(audioContext), + ), + ElevatedButton.icon( + icon: const Icon(Icons.looks_one), + label: const Text('Local'), + onPressed: () => player.setAudioContext(audioContext), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: Tabs( + tabs: [ + TabData( + key: 'contextTab-genericFlags', + label: 'Generic Flags', + content: _genericTab(), + ), + TabData( + key: 'contextTab-android', + label: 'Android', + content: _androidTab(), + ), + TabData( + key: 'contextTab-ios', + label: 'iOS', + content: _iosTab(), + ), + ], + ), + ), + ], + ); + } + + void updateConfig(AudioContextConfig newConfig) { + try { + final context = newConfig.build(); + setState(() { + audioContextConfig = newConfig; + audioContext = context; + }); + } on AssertionError catch (e) { + toast(e.message.toString()); + } + } + + void updateAudioContextAndroid(AudioContextAndroid contextAndroid) { + setState(() { + audioContext = audioContext.copy(android: contextAndroid); + }); + } + + void updateAudioContextIOS(AudioContextIOS Function() buildContextIOS) { + try { + final context = buildContextIOS(); + setState(() { + audioContext = audioContext.copy(iOS: context); + }); + } on AssertionError catch (e) { + toast(e.message.toString()); + } + } + + Widget _genericTab() { + return TabContent( + children: [ + LabeledDropDown( + label: 'Audio Route', + key: const Key('audioRoute'), + options: {for (final e in AudioContextConfigRoute.values) e: e.name}, + selected: audioContextConfig.route, + onChange: (v) => updateConfig( + audioContextConfig.copy(route: v), + ), + ), + LabeledDropDown( + label: 'Audio Focus', + key: const Key('audioFocus'), + options: {for (final e in AudioContextConfigFocus.values) e: e.name}, + selected: audioContextConfig.focus, + onChange: (v) => updateConfig( + audioContextConfig.copy(focus: v), + ), + ), + Cbx( + 'Respect Silence', + value: audioContextConfig.respectSilence, + ({value}) => + updateConfig(audioContextConfig.copy(respectSilence: value)), + ), + Cbx( + 'Stay Awake', + value: audioContextConfig.stayAwake, + ({value}) => updateConfig(audioContextConfig.copy(stayAwake: value)), + ), + ], + ); + } + + Widget _androidTab() { + return TabContent( + children: [ + Cbx( + 'isSpeakerphoneOn', + value: audioContext.android.isSpeakerphoneOn, + ({value}) => updateAudioContextAndroid( + audioContext.android.copy(isSpeakerphoneOn: value), + ), + ), + Cbx( + 'stayAwake', + value: audioContext.android.stayAwake, + ({value}) => updateAudioContextAndroid( + audioContext.android.copy(stayAwake: value), + ), + ), + LabeledDropDown( + label: 'contentType', + key: const Key('contentType'), + options: {for (final e in AndroidContentType.values) e: e.name}, + selected: audioContext.android.contentType, + onChange: (v) => updateAudioContextAndroid( + audioContext.android.copy(contentType: v), + ), + ), + LabeledDropDown( + label: 'usageType', + key: const Key('usageType'), + options: {for (final e in AndroidUsageType.values) e: e.name}, + selected: audioContext.android.usageType, + onChange: (v) => updateAudioContextAndroid( + audioContext.android.copy(usageType: v), + ), + ), + LabeledDropDown( + key: const Key('audioFocus'), + label: 'audioFocus', + options: {for (final e in AndroidAudioFocus.values) e: e.name}, + selected: audioContext.android.audioFocus, + onChange: (v) => updateAudioContextAndroid( + audioContext.android.copy(audioFocus: v), + ), + ), + LabeledDropDown( + key: const Key('audioMode'), + label: 'audioMode', + options: {for (final e in AndroidAudioMode.values) e: e.name}, + selected: audioContext.android.audioMode, + onChange: (v) => updateAudioContextAndroid( + audioContext.android.copy(audioMode: v), + ), + ), + ], + ); + } + + Widget _iosTab() { + final iosOptions = AVAudioSessionOptions.values.map( + (option) { + final options = {...audioContext.iOS.options}; + return Cbx( + option.name, + value: options.contains(option), + ({value}) { + updateAudioContextIOS(() { + final iosContext = audioContext.iOS.copy(options: options); + if (value ?? false) { + options.add(option); + } else { + options.remove(option); + } + return iosContext; + }); + }, + ); + }, + ).toList(); + return TabContent( + children: [ + LabeledDropDown( + key: const Key('category'), + label: 'category', + options: {for (final e in AVAudioSessionCategory.values) e: e.name}, + selected: audioContext.iOS.category, + onChange: (v) => updateAudioContextIOS( + () => audioContext.iOS.copy(category: v), + ), + ), + ...iosOptions, + ], + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/packages/audioplayers/example/lib/tabs/controls.dart b/packages/audioplayers/example/lib/tabs/controls.dart new file mode 100644 index 0000000..04e3857 --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/controls.dart @@ -0,0 +1,243 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/components/btn.dart'; +import 'package:audioplayers_elinux_example/components/list_tile.dart'; +import 'package:audioplayers_elinux_example/components/tab_content.dart'; +import 'package:audioplayers_elinux_example/components/tgl.dart'; +import 'package:audioplayers_elinux_example/components/txt.dart'; +import 'package:audioplayers_elinux_example/utils.dart'; +import 'package:flutter/material.dart'; + +class ControlsTab extends StatefulWidget { + final AudioPlayer player; + + const ControlsTab({ + required this.player, + super.key, + }); + + @override + State createState() => _ControlsTabState(); +} + +class _ControlsTabState extends State + with AutomaticKeepAliveClientMixin { + String modalInputSeek = ''; + + Future _update(Future Function() fn) async { + await fn(); + // update everyone who listens to "player" + setState(() {}); + } + + Future _seekPercent(double percent) async { + final duration = await widget.player.getDuration(); + if (duration == null) { + toast( + 'Failed to get duration for proportional seek.', + textKey: const Key('toast-proportional-seek-duration-null'), + ); + return; + } + final position = duration * percent; + _seekDuration(position); + } + + Future _seekDuration(Duration position) async { + await _update( + () => widget.player.seek(position), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return TabContent( + children: [ + WrappedListTile( + children: [ + Btn( + key: const Key('control-pause'), + txt: 'Pause', + onPressed: widget.player.pause, + ), + Btn( + key: const Key('control-stop'), + txt: 'Stop', + onPressed: widget.player.stop, + ), + Btn( + key: const Key('control-resume'), + txt: 'Resume', + onPressed: widget.player.resume, + ), + Btn( + key: const Key('control-release'), + txt: 'Release', + onPressed: widget.player.release, + ), + ], + ), + WrappedListTile( + leading: const Text('Volume'), + children: [0.0, 0.5, 1.0, 2.0].map((it) { + final formattedVal = it.toStringAsFixed(1); + return Btn( + key: Key('control-volume-$formattedVal'), + txt: formattedVal, + onPressed: () => widget.player.setVolume(it), + ); + }).toList(), + ), + WrappedListTile( + leading: const Text('Balance'), + children: [-1.0, -0.5, 0.0, 1.0].map((it) { + final formattedVal = it.toStringAsFixed(1); + return Btn( + key: Key('control-balance-$formattedVal'), + txt: formattedVal, + onPressed: () => widget.player.setBalance(it), + ); + }).toList(), + ), + WrappedListTile( + leading: const Text('Rate'), + children: [0.0, 0.5, 1.0, 2.0].map((it) { + final formattedVal = it.toStringAsFixed(1); + return Btn( + key: Key('control-rate-$formattedVal'), + txt: formattedVal, + onPressed: () => widget.player.setPlaybackRate(it), + ); + }).toList(), + ), + WrappedListTile( + leading: const Text('Player Mode'), + children: [ + EnumTgl( + key: const Key('control-player-mode'), + options: { + for (final e in PlayerMode.values) + 'control-player-mode-${e.name}': e, + }, + selected: widget.player.mode, + onChange: (playerMode) async { + await _update(() => widget.player.setPlayerMode(playerMode)); + }, + ), + ], + ), + WrappedListTile( + leading: const Text('Release Mode'), + children: [ + EnumTgl( + key: const Key('control-release-mode'), + options: { + for (final e in ReleaseMode.values) + 'control-release-mode-${e.name}': e, + }, + selected: widget.player.releaseMode, + onChange: (releaseMode) async { + await _update( + () => widget.player.setReleaseMode(releaseMode), + ); + }, + ), + ], + ), + WrappedListTile( + leading: const Text('Seek'), + children: [ + ...[0.0, 0.5, 1.0].map((it) { + final formattedVal = it.toStringAsFixed(1); + return Btn( + key: Key('control-seek-$formattedVal'), + txt: formattedVal, + onPressed: () => _seekPercent(it), + ); + }), + Btn( + txt: 'Custom', + onPressed: () async { + dialog( + _SeekDialog( + value: modalInputSeek, + setValue: (it) => setState(() => modalInputSeek = it), + seekDuration: () => _seekDuration( + Duration( + milliseconds: int.parse(modalInputSeek), + ), + ), + seekPercent: () => _seekPercent( + double.parse(modalInputSeek), + ), + ), + ); + }, + ), + ], + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} + +class _SeekDialog extends StatelessWidget { + final VoidCallback seekDuration; + final VoidCallback seekPercent; + final void Function(String val) setValue; + final String value; + + const _SeekDialog({ + required this.seekDuration, + required this.seekPercent, + required this.value, + required this.setValue, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Pick a duration and unit to seek'), + TxtBox( + value: value, + onChange: setValue, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Btn( + txt: 'millis', + onPressed: () { + Navigator.of(context).pop(); + seekDuration(); + }, + ), + Btn( + txt: 'seconds', + onPressed: () { + Navigator.of(context).pop(); + seekDuration(); + }, + ), + Btn( + txt: '%', + onPressed: () { + Navigator.of(context).pop(); + seekPercent(); + }, + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + ], + ), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/tabs/logger.dart b/packages/audioplayers/example/lib/tabs/logger.dart new file mode 100644 index 0000000..bff4535 --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/logger.dart @@ -0,0 +1,185 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/components/btn.dart'; +import 'package:flutter/material.dart'; + +class LoggerTab extends StatefulWidget { + final AudioPlayer player; + + const LoggerTab({ + required this.player, + super.key, + }); + + @override + LoggerTabState createState() => LoggerTabState(); +} + +class LoggerTabState extends State + with AutomaticKeepAliveClientMixin { + AudioLogLevel get currentLogLevel => AudioLogger.logLevel; + + set currentLogLevel(AudioLogLevel level) { + AudioLogger.logLevel = level; + } + + List logs = []; + List globalLogs = []; + + @override + void initState() { + super.initState(); + AudioPlayer.global.onLog.listen( + (message) { + if (AudioLogLevel.info.level <= currentLogLevel.level) { + setState(() { + globalLogs.add(Log(message, level: AudioLogLevel.info)); + }); + } + }, + onError: (Object o, [StackTrace? stackTrace]) { + if (AudioLogLevel.error.level <= currentLogLevel.level) { + setState(() { + globalLogs.add( + Log( + AudioLogger.errorToString(o, stackTrace), + level: AudioLogLevel.error, + ), + ); + }); + } + }, + ); + widget.player.onLog.listen( + (message) { + if (AudioLogLevel.info.level <= currentLogLevel.level) { + final msg = '$message\nSource: ${widget.player.source}'; + setState(() { + logs.add(Log(msg, level: AudioLogLevel.info)); + }); + } + }, + onError: (Object o, [StackTrace? stackTrace]) { + if (AudioLogLevel.error.level <= currentLogLevel.level) { + setState(() { + logs.add( + Log( + AudioLogger.errorToString( + AudioPlayerException(widget.player, cause: o), + stackTrace, + ), + level: AudioLogLevel.error, + ), + ); + }); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + ListTile( + title: Text(currentLogLevel.toString()), + subtitle: const Text('Log Level'), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: AudioLogLevel.values + .map( + (level) => Btn( + txt: level.toString().replaceAll('AudioLogLevel.', ''), + onPressed: () { + setState(() => currentLogLevel = level); + }, + ), + ) + .toList(), + ), + const Divider(color: Colors.black), + Expanded( + child: LogView( + title: 'Player Logs:', + logs: logs, + onDelete: () => setState(() { + logs.clear(); + }), + ), + ), + const Divider(color: Colors.black), + Expanded( + child: LogView( + title: 'Global Logs:', + logs: globalLogs, + onDelete: () => setState(() { + globalLogs.clear(); + }), + ), + ), + ], + ), + ); + } + + @override + bool get wantKeepAlive => true; +} + +class LogView extends StatelessWidget { + final String title; + final List logs; + final VoidCallback onDelete; + + const LogView({ + required this.logs, + required this.title, + required this.onDelete, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title), + IconButton(onPressed: onDelete, icon: const Icon(Icons.delete)), + ], + ), + Expanded( + child: ListView( + children: logs + .map( + (log) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + '${log.level}: ${log.message}', + style: log.level == AudioLogLevel.error + ? const TextStyle(color: Colors.red) + : null, + ), + Divider(color: Colors.grey.shade400), + ], + ), + ) + .toList(), + ), + ), + ], + ); + } +} + +class Log { + Log(this.message, {required this.level}); + + final AudioLogLevel level; + final String message; +} diff --git a/packages/audioplayers/example/lib/tabs/sources.dart b/packages/audioplayers/example/lib/tabs/sources.dart new file mode 100644 index 0000000..7f2bab9 --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/sources.dart @@ -0,0 +1,471 @@ +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/components/btn.dart'; +import 'package:audioplayers_elinux_example/components/drop_down.dart'; +import 'package:audioplayers_elinux_example/components/tab_content.dart'; +import 'package:audioplayers_elinux_example/utils.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; + +const useLocalServer = bool.fromEnvironment('USE_LOCAL_SERVER'); + +final localhost = kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; +final host = useLocalServer ? 'http://$localhost:8080' : 'https://luan.xyz'; + +final wavUrl1 = '$host/files/audio/coins.wav'; +final wavUrl2 = '$host/files/audio/laser.wav'; +final wavUrl3 = '$host/files/audio/coins_non_ascii_и.wav'; +final mp3Url1 = '$host/files/audio/ambient_c_motion.mp3'; +final mp3Url2 = '$host/files/audio/nasa_on_a_mission.mp3'; +final m3u8StreamUrl = useLocalServer + ? '$host/files/live_streams/nasa_power_of_the_rovers.m3u8' + : 'https://a.files.bbci.co.uk/media/live/manifesto/audio/simulcast/hls/nonuk/sbr_low/ak/bbc_world_service.m3u8'; +final mpgaStreamUrl = useLocalServer + ? '$host/stream/mpeg' + : 'https://timesradio.wireless.radio/stream'; + +const wavAsset1 = 'coins.wav'; +const wavAsset2 = 'laser.wav'; +const mp3Asset = 'nasa_on_a_mission.mp3'; +const invalidAsset = 'invalid.txt'; +const specialCharAsset = 'coins_non_ascii_и.wav'; +const noExtensionAsset = 'coins_no_extension'; +const wavDataUri = + 'data:audio/x-wav;base64,UklGRoibAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YWSbAABPMMkvRC++Ljkusy0uLagsIyydKxgrkyoNKogpAyl9KPgncyfuJmgm4yVeJdkkVCTmCJPcGN2e3SPeqd4u37TfOeC/4EThyeFP4tTiWePf42Tk6eRv5fTleeb+5v/m/+YA538M/hj9GPwY/Bj7GPoY+Rj4GPcY9xj2GPUY9BjzGPIY8hjxGPAY7xjuGO0Y7RjsGOsY/eb+5v7m/+YA5wHnAucD5wPnBOcF5wbnB+cI5wjnCecK5wvnDOcN5w3nDucP5xDnxPnuGO4Y7RjsGOsY6hjpGOkY6BjnGOYY5RjkGOQY4xjiGOEY4BjfGN8Y3hjdGNwY2xi6+Q3nDucP5xDnEOcR5xLnE+cU5xXnFecW5xfnGOcZ5xrnGucb5xznHece5x/nH+cg598Y3hjdGNwY2xjbGNoY2RjYGNcY1hjWGNUY1BjTGNIY0RjRGNAYzxjOGM0YzBjMGF8MHece5x7nH+cg5yHnIucj5yPnJOcl5ybnJ+co5yjnKecq5yvnLOct5y3nLucv5zDnNAbOGM0YzRjMGMsYyhjJGMgYyBjHGMYYxRjEGMMYwxjCGMEYwBi/GL4Yvhi9GLwYuxhe7S3nLucv5zDnMOcx5zLnM+c05zXnNec25zfnOOc55zrnOuc75zznPec+5z/nP+eg878Yvhi9GLwYuxi6GLoYuRi4GLcYthi1GLUYtBizGLIYsRiwGLAYrxiuGK0YrBisGPP/Pec+5z7nP+dA50HnQudD50PnROdF50bnR+dI50jnSedK50vnTOdM503nTudP51DngxKuGK0YrBisGKsYqhipGKgYpxinGKYYpRikGKMYohiiGKEYoBifGJ4YnhidGJwYcRJM503nTudP51DnUOdR51LnU+dU51XnVedW51fnWOdZ51rnWudb51znXede51/nX+cAAJ4YnhidGJwYmxiaGJkYmRiYGJcYlhiVGJQYlBiTGJIYkRiQGJAYjxiOGI0YjBiLGKjzXede517nX+dg52HnYudj52PnZOdl52bnZ+dn52jnaedq52vnbOds523nbudv55TtjxiOGI0YjBiLGIsYihiJGIgYhxiGGIYYhRiEGIMYghiCGIEYgBh/GH4YfRh9GHwYFQZs523nbudv53DncOdx53Lnc+d053Xnded253fneOd553rneud753znfed+53/nf+c/DH4YfRh9GHwYexh6GHkYeBh4GHcYdhh1GHQYdBhzGHIYcRhwGG8YbxhuGG0YbBhrGHznfed+537nf+eA54HngueD54PnhOeF54bnh+eH54jnieeK54vnjOeM543njueP5+P5bxhuGG0YbBhrGGoYahhpGGgYZxhmGGYYZRhkGGMYYhhhGGEYYBhfGF4YXRhdGFwY2fmM543njueP55DnkOeR55Lnk+eU55XnleeW55fnmOeZ55rnmueb55znneee557nn+dfGF4YXRhcGFwYWxhaGFkYWBhYGFcYVhhVGFQYUxhTGFIYURhQGE8YTxhOGE0YTBgfDJznneee557nn+eg56Hnouej56PnpOel56bnp+en56jnqeeq56vnrOes563nruev5xMGThhOGE0YTBhLGEoYShhJGEgYRxhGGEUYRRhEGEMYQhhBGEEYQBg/GD4YPRg8GDwYvu2s563nruev57Dnseex57Lns+e057Xntee257fnuOe557rnuue757znvee+577n3/M/GD4YPRg8GDwYOxg6GDkYOBg3GDcYNhg1GDQYMxgzGDIYMRgwGC8YLhguGC0YLBjz/7znvee+577nv+fA58HnwufD58PnxOfF58bnx+fH58jnyefK58vnzOfM583nzufP5yMSLhguGC0YLBgrGCoYKRgpGCgYJxgmGCUYJRgkGCMYIhghGCAYIBgfGB4YHRgcGBESzOfM583nzufP59Dn0efR59Ln0+fU59Xn1efW59fn2OfZ59rn2ufb59zn3efe597n//8fGB4YHRgcGBsYGxgaGBkYGBgXGBYYFhgVGBQYExgSGBIYERgQGA8YDhgOGA0YDBjn89zn3efe597n3+fg5+Hn4ufj5+Pn5Ofl5+bn5+fn5+jn6efq5+vn7Ofs5+3n7ufz7Q8YDhgNGA0YDBgLGAoYCRgIGAgYBxgGGAUYBBgEGAMYAhgBGAAYABj/F/4X/Rf8F/UF7Ofs5+3n7ufv5/Dn8efx5/Ln8+f05/Xn9ef25/fn+Of55/rn+uf75/zn/ef+5/7n/wv/F/4X/Rf8F/sX+hf6F/kX+Bf3F/YX9hf1F/QX8xfyF/EX8RfwF+8X7hftF+0X7Bf75/zn/ef+5//n/+cA6AHoAugD6APoBOgF6AboB+gH6AjoCegK6AvoDOgM6A3oDugD+u8X7hftF+wX7BfrF+oX6RfoF+gX5xfmF+UX5BfjF+MX4hfhF+AX3xffF94X3RfcF/n5DOgM6A3oDugP6BDoEegR6BLoE+gU6BXoFegW6BfoGOgZ6BnoGugb6BzoHege6B7o3xfeF94X3RfcF9sX2hfaF9kX2BfXF9YX1RfVF9QX0xfSF9EX0RfQF88XzhfNF80X4Asb6BzoHege6B/oH+gg6CHoIugj6CPoJOgl6CboJ+gn6CjoKegq6CvoLOgs6C3oLujzBc8XzhfNF8wXyxfLF8oXyRfIF8cXxxfGF8UXxBfDF8MXwhfBF8AXvxe/F74XvRe8Fx3uLOgt6C3oLugv6DDoMegx6DLoM+g06DXoNeg26DfoOOg56DnoOug76DzoPeg+6B/0vxe+F70XvRe8F7sXuhe5F7kXuBe3F7YXtRe1F7QXsxeyF7EXsRewF68XrhetF6wX8/876DzoPeg+6D/oP+hA6EHoQuhD6EPoROhF6EboR+hH6EjoSehK6EvoS+hM6E3oTujDEa8XrhetF6wXqxerF6oXqReoF6cXpxemF6UXpBejF6IXohehF6AXnxeeF54XnReyEUvoTOhN6E3oTuhP6FDoUehR6FLoU+hU6FXoVehW6FfoWOhZ6FnoWuhb6FzoXehe6P//nxeeF50XnRecF5sXmheZF5gXmBeXF5YXlReUF5QXkxeSF5EXkBeQF48XjheNF4wXJ/Rb6FzoXehe6F/oX+hg6GHoYuhj6GPoZOhl6GboZ+hn6Gjoaehq6Gvoa+hs6G3oUu6PF44XjheNF4wXixeKF4oXiReIF4cXhheGF4UXhBeDF4IXgheBF4AXfxd+F34XfRfVBWvobOht6G3obuhv6HDocehx6HLoc+h06HXodeh26HfoeOh56Hnoeuh76Hzofeh96L8Lfxd+F30XfBd8F3sXehd5F3gXeBd3F3YXdRd0F3QXcxdyF3EXcBdwF28XbhdtF2wXe+h76Hzofeh+6H/of+iA6IHoguiD6IPohOiF6Iboh+iH6IjoieiK6Ivoi+iM6I3oI/pvF24XbhdtF2wXaxdqF2oXaRdoF2cXZhdmF2UXZBdjF2IXYRdhF2AXXxdeF14XXRcZ+ovojOiN6I3ojuiP6JDokeiR6JLok+iU6JXoleiW6JfomOiZ6Jnomuib6Jzoneid6F8XXxdeF10XXBdbF1sXWhdZF1gXVxdXF1YXVRdUF1MXUxdSF1EXUBdPF08XThdNF6ALm+ib6Jzoneie6J/on+ig6KHoouij6KPopOil6Kbop+in6Kjoqeiq6Kvoq+is6K3o0wVPF04XTRdNF0wXSxdKF0kXSRdIF0cXRhdFF0UXRBdDF0IXQRdBF0AXPxc+Fz0XPRd87qvorOit6K3oruiv6LDoseix6LLos+i06LXotei26LfouOi56Lnouui76Lzovehe9D8XPxc+Fz0XPBc7FzsXOhc5FzgXNxc3FzYXNRc0FzMXMxcyFzEXMBcvFy8XLhctF/P/u+i76Lzovei+6L/ov+jA6MHowujD6MPoxOjF6Mbox+jH6MjoyejK6Mvoy+jM6M3oYxEvFy4XLRctFywXKxcqFykXKRcoFycXJhclFyUXJBcjFyIXIRchFyAXHxceFx0XUhHK6MvozOjN6M3ozujP6NDo0ejR6NLo0+jU6NXo1ejW6Nfo2OjZ6Nno2ujb6Nzo3ej//x8XHhceFx0XHBcbFxoXGhcZFxgXFxcWFxYXFRcUFxMXEhcSFxEXEBcPFw8XDhcNF2f02+jb6Nzo3eje6N/o3+jg6OHo4ujj6OPo5Ojl6Obo5+jn6Ojo6ejq6Ovo6+js6LHuEBcPFw4XDRcMFwwXCxcKFwkXCBcIFwcXBhcFFwQXBBcDFwIXARcAFwAX/xb+Fv0WtgXq6Ovo7Ojt6O3o7ujv6PDo8ejx6PLo8+j06PXo9ej26Pfo+Oj56Pno+uj76Pzo/eh/C/8W/hb+Fv0W/Bb7FvoW+hb5FvgW9xb2FvYW9Rb0FvMW8hbyFvEW8BbvFu4W7hbtFvro++j86Pzo/ej+6P/o/+gA6QHpAukD6QPpBOkF6QbpB+kH6QjpCekK6QvpC+kM6UL67xbvFu4W7RbsFusW6xbqFukW6BboFucW5hblFuQW5BbjFuIW4RbgFuAW3xbeFt0WOfoK6QvpDOkN6Q7pDukP6RDpEekR6RLpE+kU6RXpFekW6RfpGOkZ6RnpGukb6RzpHengFt8W3hbdFt0W3BbbFtoW2RbZFtgW1xbWFtYW1RbUFtMW0hbSFtEW0BbPFs4WzhZgCxrpG+kc6RzpHeke6R/pIOkg6SHpIukj6SPpJOkl6SbpJ+kn6SjpKekq6SvpK+ks6bMFzxbPFs4WzRbMFssWyxbKFskWyBbHFscWxhbFFsQWwxbDFsIWwRbAFsAWvxa+Fr0W3O4q6SvpLOkt6S7pLukv6TDpMeky6TLpM+k06TXpNek26TfpOOk56TnpOuk76TzpnfTAFr8Wvha9Fr0WvBa7FroWuRa5FrgWtxa2FrUWtRa0FrMWshaxFrEWsBavFq4Wrhbz/zrpO+k86TzpPek+6T/pQOlA6UHpQulD6UPpROlF6UbpR+lH6UjpSelK6UvpS+lM6QQRrxauFq4WrRasFqsWqxaqFqkWqBanFqcWphalFqQWoxajFqIWoRagFp8WnxaeFvMQSulK6UvpTOlN6U7pTulP6VDpUelS6VLpU+lU6VXpVelW6VfpWOlZ6VnpWulb6Vzp/v+gFp8WnhadFpwWnBabFpoWmRaYFpgWlxaWFpUWlRaUFpMWkhaRFpEWkBaPFo4Wjham9FrpW+lc6VzpXele6V/pYOlg6WHpYulj6WTpZOll6WbpZ+ln6Wjpaelq6Wvpa+kR75AWjxaOFo4WjRaMFosWihaKFokWiBaHFoYWhhaFFoQWgxaDFoIWgRaAFn8WfxZ+FpYFaulq6WvpbOlt6W7pbulv6XDpcely6XLpc+l06XXpdel26XfpeOl56Xnpeul76XzpPwt/Fn8WfhZ9FnwWfBZ7FnoWeRZ4FngWdxZ2FnUWdBZ0FnMWchZxFnEWcBZvFm4WbRZ56Xrpe+l86Xzpfel+6X/pgOmA6YHpgumD6YTphOmF6Ybph+mH6YjpiemK6Yvpi+li+nAWbxZuFm0WbRZsFmsWahZqFmkWaBZnFmYWZhZlFmQWYxZjFmIWYRa/9Irpi+mM6Y3pjumO6Y/pkOmR6ZHpkumT6ZTplemV6Zbpl+mY6ZjpzPRjFmIWYRZgFl8WXxZeFl0WXBZcFlsWWhZZFlgWWBZXFlYWVRZVFlQWXfqY6Zjpmema6Zvpm+mc6Z3pnumf6Z/poOmh6aLpoumj6aTppemm6TzvVRZVFlQWUxZSFlEWURZQFk8WThZOFk0WTBZLFkoWShZJFkgWRxZHFvX/peml6abpp+mo6anpqemq6avprOms6a3prumv6bDpsOmx6bLps+m06UgWRxZHFkYWRRZEFkMWQxZCFkEWQBZAFj8WPhY9FjwWPBY7FjoWORaGBbLps+mz6bTptem26bfpt+m46bnpuum66bvpvOm96b7pvum/6cDpwemsEDoWORY5FjgWNxY2FjYWNRY0FjMWMhYyFjEWMBYvFi8WLhYtFiwWEAu/6cDpwenB6cLpw+nE6cTpxenG6cfpyOnI6cnpyunL6cvpzOnN6c7pFgstFiwWKxYrFioWKRYoFigWJxYmFiUWJBYkFiMWIhYhFiEWIBYfFpQQzOnN6c7pzunP6dDp0enS6dLp0+nU6dXp1enW6dfp2OnZ6dnp2unb6YcFIBYfFh4WHRYdFhwWGxYaFhoWGRYYFhcWFhYWFhUWFBYTFhMWEhYRFtnp2unb6dzp3Ond6d7p3+nf6eDp4eni6ePp4+nk6eXp5unm6efp6On+/xMWEhYRFhAWDxYPFg4WDRYMFgwWCxYKFgkWCBYIFgcWBhYFFgUWBBZq7+fp6Onp6erp6unr6ezp7ent6e7p7+nw6fHp8eny6fPp9On06fXpfPoFFgUWBBYDFgIWARYBFgAW/xX+Ff4V/RX8FfsV+xX6FfkV+BX3FfcV9PT06fXp9un36ffp+On56frp++n76fzp/en+6f7p/+kA6gHqAuoC6gD1+BX3FfcV9hX1FfQV9BXzFfIV8RXwFfAV7xXuFe0V7RXsFesV6hXpFXj6AuoC6gPqBOoF6gXqBuoH6gjqCOoJ6grqC+oM6gzqDeoO6g/qD+qM7+sV6hXpFekV6BXnFeYV5hXlFeQV4xXiFeIV4RXgFd8V3xXeFd0V3BX1/w/qD+oQ6hHqEuoT6hPqFOoV6hbqFuoX6hjqGeoZ6hrqG+oc6h3qHereFd0V3BXbFdsV2hXZFdgV2BXXFdYV1RXUFdQV0xXSFdEV0RXQFc8VawUc6h3qHeoe6h/qIOog6iHqIuoj6iTqJOol6ibqJ+on6ijqKeoq6irqXBDQFc8VzhXNFc0VzBXLFcoVyhXJFcgVxxXHFcYVxRXEFcMVwxXCFdsKKeoq6irqK+os6i3qLuou6i/qMOox6jHqMuoz6jTqNeo16jbqN+o46uEKwxXCFcEVwBW/Fb8VvhW9FbwVvBW7FboVuRW5FbgVtxW2FbYVtRVEEDbqN+o46jjqOeo66jvqO+o86j3qPuo/6j/qQOpB6kLqQupD6kTqRepsBbUVtRW0FbMVshWyFbEVsBWvFa4VrhWtFawVqxWrFaoVqRWoFagVpxVD6kTqRepG6kbqR+pI6knqSepK6kvqTOpM6k3qTupP6lDqUOpR6lLq/v+oFacVpxWmFaUVpBWkFaMVohWhFaAVoBWfFZ4VnRWdFZwVmxWaFZoVuu9R6lLqU+pT6lTqVepW6lfqV+pY6lnqWupa6lvqXOpd6l3qXupf6pb6mxWaFZkVmRWYFZcVlhWWFZUVlBWTFZMVkhWRFZAVjxWPFY4VjRWMFSn1Xupf6mDqYeph6mLqY+pk6mTqZepm6mfqaOpo6mnqaupr6mvqbOo19Y4VjRWMFYsVixWKFYkViBWIFYcVhhWFFYUVhBWDFYIVghWBFYAVfxWS+mvqbOpt6m7qb+pv6nDqcepy6nLqc+p06nXqdep26nfqeOp46nnq2++BFYAVfxV+FX0VfRV8FXsVehV6FXkVeBV3FXcVdhV1FXQVdBVzFXIV9f956nnqeup76nzqfOp96n7qf+qA6oDqgeqC6oPqg+qE6oXqhuqG6ofqcxVzFXIVcRVwFXAVbxVuFW0VbBVsFWsVahVpFWkVaBVnFWYVZhVlFVEFhuqH6ofqiOqJ6orqiuqL6ozqjeqN6o7qj+qQ6pDqkeqS6pPqlOqU6gwQZRVlFWQVYxViFWIVYRVgFV8VXxVeFV0VXBVbFVsVWhVZFVgVWBWmCpPqlOqU6pXqluqX6pjqmOqZ6prqm+qb6pzqneqe6p7qn+qg6qHqoeqrClgVVxVXFVYVVRVUFVQVUxVSFVEVURVQFU8VThVOFU0VTBVLFUsV9Q+g6qHqouqi6qPqpOql6qXqpuqn6qjqqOqp6qrqq+qs6qzqrequ6q/qUQVLFUoVSRVJFUgVRxVGFUYVRRVEFUMVQxVCFUEVQBVAFT8VPhU9FT0Vrequ6q/qsOqw6rHqsuqz6rPqtOq16rbqtuq36rjqueq56rrqu+q86v3/PhU9FTwVOxU7FToVORU4FTgVNxU2FTUVNRU0FTMVMhUyFTEVMBUvFQnwu+q86r3qveq+6r/qwOrA6sHqwurD6sTqxOrF6sbqx+rH6sjqyeqw+jEVMBUvFS4VLhUtFSwVKxUrFSoVKRUoFScVJxUmFSUVJBUkFSMVIhVe9cjqyerK6svqy+rM6s3qzurO6s/q0OrR6tHq0urT6tTq1OrV6tbqavUjFSMVIhUhFSAVIBUfFR4VHRUdFRwVGxUaFRoVGRUYFRcVFxUWFRUVrfrW6tbq1+rY6tnq2era6tvq3Orc6t3q3urf6t/q4Orh6uLq4urj6irwFhUVFRUVFBUTFRIVEhURFRAVDxUPFQ4VDRUMFQwVCxUKFQkVCRUIFfX/4+rj6uTq5erm6ubq5+ro6unq6erq6uvq7Ors6u3q7urv6vDq8Orx6gkVCBUHFQcVBhUFFQQVBBUDFQIVARUBFQAV/xT+FP4U/RT8FPsU+xQ2BfDq8erx6vLq8+r06vTq9er26vfq9+r46vnq+ur66vvq/Or96v3q/uq8D/sU+hT6FPkU+BT3FPYU9hT1FPQU8xTzFPIU8RTwFPAU7xTuFO0UcQr96v7q/ur/6gDrAesC6wLrA+sE6wXrBesG6wfrCOsI6wnrCusL6wvrdgruFO0U7BTsFOsU6hTpFOkU6BTnFOYU5hTlFOQU4xTjFOIU4RTgFKUPCusL6wzrDOsN6w7rD+sP6xDrEesS6xLrE+sU6xXrFesW6xfrGOsY6zYF4RTgFN8U3hTeFN0U3BTbFNsU2hTZFNgU2BTXFNYU1RTVFNQU0xTSFBfrGOsZ6xrrGusb6xzrHesd6x7rH+sg6yDrIesi6yPrI+sk6yXrJuv9/9MU0xTSFNEU0BTQFM8UzhTNFM0UzBTLFMoUyhTJFMgUxxTHFMYUxRRZ8CXrJusn6yfrKOsp6yrrKusr6yzrLest6y7rL+sw6zDrMesy6zPry/rGFMUUxRTEFMMUwhTCFMEUwBS/FL8UvhS9FLwUvBS7FLoUuhS5FLgUk/Uy6zPrNOs16zXrNus36zjrOOs56zrrO+s76zzrPes+6z7rP+tA65/1uRS4FLgUtxS2FLUUtRS0FLMUshSyFLEUsBSvFK8UrhStFKwUrBSrFMf6QOtA60HrQutD60PrROtF60brRutH60jrSetJ60rrS+tM60zrTet68KwUqxSqFKoUqRSoFKcUpxSmFKUUpBSkFKMUohShFKEUoBSfFJ4UnhT1/03rTetO60/rUOtQ61HrUutT61PrVOtV61brVutX61jrWetZ61rrW+ufFJ4UnRScFJwUmxSaFJkUmRSYFJcUlhSWFJUUlBSTFJMUkhSRFJAUGwVa61vrW+tc613rXute61/rYOth62HrYutj62TrZOtl62brZ+tn62jrbA+RFJAUjxSOFI4UjRSMFIsUixSKFIkUiBSIFIcUhhSGFIUUhBSDFDwKZ+to62nraetq62vrbOts623rbutu62/rcOtx63Hrcutz63TrdOt160EKgxSDFIIUgRSBFIAUfxR+FH4UfRR8FHsUexR6FHkUeBR4FHcUdhRVD3Trdet263brd+t463nreet663vrfOt8633rfut/63/rgOuB64LrgusbBXYUdhR1FHQUcxRzFHIUcRRwFHAUbxRuFG0UbRRsFGsUahRqFGkUaBSB64Lrg+uE64TrheuG64frh+uI64nriuuK64vrjOuN643rjuuP64/r/f9pFGgUaBRnFGYUZRRlFGQUYxRiFGIUYRRgFF8UXxReFF0UXRRcFFsUqPCP65DrkeuS65Lrk+uU65TrleuW65frl+uY65nrmuua65vrnOud6+X6XBRbFFoUWhRZFFgUVxRXFFYUVRRUFFQUUxRSFFIUURRQFE8UTxROFMj1nOud657rn+uf66Droeui66Lro+uk66Xrpeum66frqOuo66nrquvU9U8UThRNFEwUTBRLFEoUShRJFEgURxRHFEYURRREFEQUQxRCFEEUQRTi+qrrquur66zrreut667rr+uw67Drseuy67Lrs+u067Xrteu267fryfBBFEEUQBQ/FD8UPhQ9FDwUPBQ7FDoUORQ5FDgUNxQ2FDYUNRQ0FDMU9f+367jruOu567rruuu767zrveu9677rv+vA68DrwevC68Prw+vE68XrNBQ0FDMUMhQxFDEUMBQvFC4ULhQtFCwUKxQrFCoUKRQpFCgUJxQmFAEFxOvF68XrxuvH68jryOvJ68rry+vL68zrzevO687rz+vQ69Dr0evS6x0PJhQmFCUUJBQjFCMUIhQhFCAUIBQfFB4UHhQdFBwUGxQbFBoUGRQGCtHr0uvT69Pr1OvV69br1uvX69jr2OvZ69rr2+vb69zr3eve697r3+sLChkUGBQYFBcUFhQVFBUUFBQTFBMUEhQRFBAUEBQPFA4UDRQNFAwUBg/e69/r4Ovh6+Hr4uvj6+Pr5Ovl6+br5uvn6+jr6evp6+rr6+vs6+zrAQUMFAsUCxQKFAkUCBQIFAcUBhQFFAUUBBQDFAIUAhQBFAAUABT/E/4T6+vs6+3r7uvu6+/r8Ovx6/Hr8uvz6/Tr9Ov16/br9uv36/jr+ev56/3//xP+E/0T/RP8E/sT+hP6E/kT+BP3E/cT9hP1E/UT9BPzE/IT8hPxE/jw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PxI+Ej0SPRI8EjsS0vav7bDtse2x7bLts+2z7bTtte217bbtt+237bjtue267brtu+287dz2OxI7EjoSORI5EjgSNxI3EjYSNRI0EjQSMxIyEjISMRIwEjASLxIuEmb7vO297b7tvu2/7cDtwO3B7cLtw+3D7cTtxe3F7cbtx+3H7cjtye1W8i4SLRItEiwSKxIrEioSKRIpEigSJxInEiYSJRIkEiQSIxIiEiISIRL1/8ntyu3L7cztzO3N7c7tzu3P7dDt0O3R7dLt0+3T7dTt1e3V7dbt1+0hEiASIBIfEh4SHhIdEhwSGxIbEhoSGRIZEhgSFxIXEhYSFRIVEhQSfATX7dft2O3Z7dnt2u3b7dzt3O3d7d7t3u3f7eDt4O3h7eLt4u3j7eTtjg0TEhISEhIREhASEBIPEg4SDhINEgwSDBILEgoSCRIJEggSBxIHEv0I5O3l7eXt5u3n7eft6O3p7ent6u3r7evt7O3t7e7t7u3v7fDt8O3x7QEJBhIFEgUSBBIDEgISAhIBEgASABL/Ef4R/hH9EfwR/BH7EfoR+RF4DfHt8u3y7fPt9O317fXt9u337fft+O357fnt+u377fvt/O397f7t/u17BPkR+BH3EfcR9hH1EfUR9BHzEfMR8hHxEfAR8BHvEe4R7hHtEewR7BH+7f/tAO4A7gHuAu4C7gPuBO4E7gXuBu4H7gfuCO4J7gnuCu4L7gvu/P/sEesR6hHpEekR6BHnEecR5hHlEeUR5BHjEeMR4hHhEeER4BHfEd4RhvIM7g3uDe4O7g/uEO4Q7hHuEu4S7hPuFO4U7hXuFu4W7hfuGO4Z7oP73hHeEd0R3BHcEdsR2hHaEdkR2BHXEdcR1hHVEdUR1BHTEdMR0hHREQf3Ge4a7hvuG+4c7h3uHe4e7h/uIO4g7iHuIu4i7iPuJO4k7iXuJu4R99ER0BHQEc8RzhHOEc0RzBHMEcsRyhHKEckRyBHIEccRxhHGEcURxBGB+ybuJ+4o7inuKe4q7ivuK+4s7i3uLe4u7i/uL+4w7jHuMe4y7jPupvLEEcMRwxHCEcERwRHAEb8RvhG+Eb0RvBG8EbsRuhG6EbkRuBG4EbcR9f807jTuNe427jbuN+447jjuOe467jvuO+487j3uPe4+7j/uP+5A7kHutxG2EbURtRG0EbMRsxGyEbERsRGwEa8R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q/Gs8SQHSw5KDkkOSQ5IDkgORw5GDkYORQ5FDkQOQw5DDkIOQQ5BDkAOQA6tCq3xrvGv8a/xsPGw8bHxsvGy8bPxtPG08bXxtfG28bfxt/G48bjxufGMAz4OPQ48DjwOOw46DjoOOQ45DjgONw43DjYONg41DjQONA4zDjIOMg678bvxvPG88b3xvvG+8b/xwPHA8cHxwfHC8cPxw/HE8cTxxfHG8cbx/P8wDjAOLw4uDi4OLQ4tDiwOKw4rDioOKg4pDigOKA4nDicOJg4lDiUOU/XI8cnxyvHK8cvxy/HM8c3xzfHO8c/xz/HQ8dDx0fHS8dLx0/HT8XL8Iw4jDiIOIQ4hDiAOHw4fDh4OHg4dDhwOHA4bDhsOGg4ZDhkOGA4YDub41vHW8dfx1/HY8dnx2fHa8dvx2/Hc8dzx3fHe8d7x3/Hf8eDx4fHv+BYOFQ4VDhQOFA4TDhIOEg4RDhAOEA4PDg8ODg4NDg0ODA4MDgsOCg5x/OPx4/Hk8eXx5fHm8efx5/Ho8ejx6fHq8erx6/Hr8ezx7fHt8e7xcvUJDggOCA4HDgYOBg4FDgUOBA4DDgMOAg4BDgEOAA4ADv8N/g3+Df0N9v/w8fHx8fHy8fLx8/H08fTx9fH28fbx9/H38fjx+fH58frx+vH78fzx/A37DfoN+g35DfkN+A33DfcN9g32DfUN9A30DfMN8g3yDfEN8Q3wDXQD/fH+8f7x//EA8gDyAfIC8gLyA/ID8gTyBfIF8gbyBvIH8gjyCPIJ8nIK7g3tDe0N7A3rDesN6g3qDekN6A3oDecN5w3mDeUN5Q3kDeMN4w3sBgryC/IM8gzyDfIO8g7yD/IP8hDyEfIR8hLyEvIT8hTyFPIV8hXyFvLvBuEN4A3fDd8N3g3eDd0N3A3cDdsN2w3aDdkN2Q3YDdgN1w3WDdYNXQoY8hjyGfIa8hryG/Ib8hzyHfId8h7yHvIf8iDyIPIh8iHyIvIj8iPycgPTDdMN0g3SDdEN0A3QDc8Nzw3ODc0NzQ3MDcwNyw3KDcoNyQ3JDcgNJfIm8ibyJ/In8ijyKfIp8iryKvIr8izyLPIt8i3yLvIv8i/yMPIw8vz/xg3GDcUNxA3EDcMNww3CDcENwQ3ADcANvw2+Db4NvQ29DbwNuw27DaP1M/Iz8jTyNfI18jbyNvI38jjyOPI58jnyOvI78jvyPPI88j3yPvKM/LkNuA24DbcNtw22DbUNtQ20DbQNsw2yDbINsQ2xDbANrw2vDa4Nrg0b+UDyQfJB8kLyQvJD8kTyRPJF8kXyRvJH8kfySPJI8knySvJK8kvyJPmsDasNqw2qDakNqQ2oDagNpw2mDaYNpQ2lDaQNow2jDaINog2hDaANjPxN8k7yTvJP8lDyUPJR8lHyUvJT8lPyVPJU8lXyVvJW8lfyV/JY8sH1nw2eDZ4NnQ2cDZwNmw2bDZoNmQ2ZDZgNmA2XDZYNlg2VDZUNlA2TDfb/WvJb8lzyXPJd8l3yXvJf8l/yYPJg8mHyYvJi8mPyY/Jk8mXyZfJm8pINkQ2QDZANjw2PDY4NjQ2NDYwNjA2LDYoNig2JDYkNiA2HDYcNhg1aA2jyaPJp8mnyavJr8mvybPJs8m3ybvJu8m/yb/Jw8nHycfJy8nLyc/IiCoQNgw2DDYINgQ2BDYANgA1/DX4Nfg19DX0NfA17DXsNeg16DXkNtwZ18nXydvJ38nfyePJ48nnyevJ68nvye/J88n3yffJ+8n7yf/KA8oDyugZ3DXYNdQ11DXQNdA1zDXINcg1xDXENcA1vDW8Nbg1uDW0NbA1sDQ4KgvKD8oPyhPKE8oXyhvKG8ofyh/KI8onyifKK8oryi/KM8ozyjfKN8lcDaQ1pDWgNaA1nDWYNZg1lDWUNZA1jDWMNYg1iDWENYA1gDV8NXw1eDY/ykPKQ8pHykvKS8pPyk/KU8pXylfKW8pbyl/KY8pjymfKZ8prym/L8/1wNXA1bDVoNWg1ZDVkNWA1XDVcNVg1WDVUNVA1UDVMNUw1SDVENUQ3z9Z3ynvKe8p/yn/Kg8qHyofKi8qLyo/Kk8qTypfKl8qbyp/Kn8qjyp/xPDU4NTg1NDU0NTA1LDUsNSg1KDUkNSA1IDUcNRw1GDUYNRQ1EDUQNUPmq8qvyq/Ks8q3yrfKu8q7yr/Kw8rDysfKx8rLys/Kz8rTytPK18ln5Qg1BDUENQA0/DT8NPg0+DT0NPA08DTsNOw06DToNOQ04DTgNNw03Daf8t/K48rnyufK68rryu/K88rzyvfK98r7yv/K/8sDywPLB8sLywvIR9jUNNA0zDTMNMg0yDTENMA0wDS8NLw0uDS4NLQ0sDSwNKw0rDSoNKQ33/8XyxfLG8sbyx/LI8sjyyfLJ8sryy/LL8szy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ofWh9aL1ovWj9aP1pPWk9aX1pfWm9ab1p/Wn9aj1qfWp9ar1P/hOCk4KTQpNCkwKTApLCksKSgpKCkkKSQpICkcKRwpGCkYKRQpFCkQK+P+t9a71rvWv9a/1sPWw9bH1sfWy9bP1s/W09bT1tfW19bb1tvW39bf1QQpBCkAKQAo/Cj8KPgo9Cj0KPAo8CjsKOwo6CjoKOQo5CjgKOAo3CogCuvW79bz1vPW99b31vvW+9b/1v/XA9cD1wfXB9cL1wvXD9cT1xPXF9aYHMwozCjIKMgoxCjEKMAowCi8KLwouCi4KLQotCiwKLAorCioKKgoRBcj1yPXJ9cn1yvXK9cv1y/XM9cz1zfXO9c71z/XP9dD10PXR9dH10vUSBSYKJgolCiUKJAokCiMKIwoiCiIKIQogCiAKHwofCh4KHgodCh0KkwfV9dX11vXW9df12PXY9dn12fXa9dr12/Xb9dz13PXd9d313vXe9d/1hAIZChkKGAoYChcKFgoWChUKFQoUChQKEwoTChIKEgoRChEKEAoQCg8K4vXj9eP15PXk9eX15fXm9eb15/Xn9ej16PXp9er16vXr9ev17PXs9f3/DAoLCgsKCgoKCgkKCQoICggKBwoHCgYKBgoFCgUKBAoDCgMKAgoCCnL48PXw9fH18fXy9fP18/X09fT19fX19fb19vX39ff1+PX49fn1+fV8/f8J/gn+Cf0J/Qn8CfwJ+wn7CfoJ+Qn5CfgJ+An3CfcJ9gn2CfUJ9Qn7+v31/vX+9f/1//UA9gD2AfYB9gL2AvYD9gP2BPYF9gX2BvYG9gf2AvvyCfEJ8AnwCe8J7wnuCe4J7QntCewJ7AnrCesJ6gnqCekJ6QnoCegJff0K9gv2C/YM9gz2DfYO9g72D/YP9hD2EPYR9hH2EvYS9hP2E/YU9o745AnkCeMJ4wniCeIJ4QnhCeAJ4AnfCd8J3gneCd0J3QncCdsJ2wnaCfn/GPYY9hn2GfYa9hr2G/Yb9hz2HPYd9h32HvYe9h/2H/Yg9iH2IfYi9tcJ1wnWCdYJ1QnVCdQJ1AnTCdIJ0gnRCdEJ0AnQCc8JzwnOCc4JzQluAiX2JfYm9ib2J/Yn9ij2KfYp9ir2KvYr9iv2LPYs9i32LfYu9i72L/ZXB8kJyQnICcgJxwnHCcYJxgnFCcUJxAnECcMJwwnCCcIJwQnBCcAJ3AQy9jP2M/Y09jT2NfY19jb2NvY39jf2OPY49jn2OfY69jr2O/Y79jz23QS8CbwJuwm7CboJugm5CbkJuAm4CbcJtwm2CbYJtQm0CbQJswmzCUQHP/ZA9kD2QfZB9kL2QvZD9kP2RPZF9kX2RvZG9kf2R/ZI9kj2SfZJ9mkCrwmvCa4JrgmtCawJrAmrCasJqgmqCakJqQmoCagJpwmnCaYJpgmlCU32TfZO9k72T/ZP9lD2UPZR9lH2UvZS9lP2U/ZU9lT2VfZV9lb2Vvb9/6IJoQmhCaAJoAmfCZ8JngmeCZ0JnQmcCZwJmwmbCZoJmgmZCZkJmAnC+Fr2W/Zb9lz2XPZd9l32XvZe9l/2X/Zg9mH2YfZi9mL2Y/Zj9mT2l/2VCZQJlAmTCZMJkgmSCZEJkQmQCZAJjwmPCY4JjQmNCYwJjAmLCYsJMPto9mj2afZp9mr2avZr9mv2bPZs9m32bfZu9m72b/Zv9nD2cPZx9jf7iAmHCYcJhgmFCYUJhAmECYMJgwmCCYIJgQmBCYAJgAl/CX8Jfgl+CZj9dfZ19nb2dvZ39nf2ePZ49nn2efZ69nr2e/Z79nz2ffZ99n72fvbe+HoJegl5CXkJeAl4CXcJdwl2CXYJdQl1CXQJdAlzCXMJcglyCXEJcQn5/4L2g/aD9oT2hPaF9oX2hvaG9of2h/aI9oj2ifaJ9or2ivaL9ov2jPZtCW0JbAlsCWsJawlqCWoJaQlpCWgJaAlnCWYJZgllCWUJZAlkCWMJUwKP9pD2kPaR9pH2kvaS9pP2k/aU9pT2lfaV9pb2lvaX9pf2mPaZ9pn2BwdfCV8JXgleCV0JXQlcCVwJWwlbCVoJWglZCVkJWAlYCVcJVwlWCacEnPad9p32nvaf9p/2oPag9qH2ofai9qL2o/aj9qT2pPal9qX2pvam9qgEUglSCVEJUQlQCVAJTwlPCU4JTglNCU0JTAlMCUsJSwlKCUoJSQn1Bqr2qvar9qv2rPas9q32rfau9q72r/av9rD2sPax9rH2svay9rP2s/ZPAkUJRQlECUQJQwlDCUIJQglBCUAJQAk/CT8JPgk+CT0JPQk8CTwJOwm39rf2uPa49rn2ufa69rv2u/a89rz2vfa99r72vva/9r/2wPbA9sH2/f84CTcJNwk2CTYJNQk1CTQJNAkzCTMJMgkyCTEJMQkwCTAJLwkvCS4JEfnF9sX2xvbG9sf2x/bI9sj2yfbJ9sr2yvbL9sv2zPbM9s32zfbO9rH9KwkqCSoJKQkpCSgJKAknCScJJgkmCSUJJQkkCSQJIwkjCSIJIgkhCWX70vbS9tP20/bU9tT21fbV9tb21/bX9tj22PbZ9tn22vba9tv22/Zs+x4JHQkdCRwJHAkbCRoJGgkZCRkJGAkYCRcJFwkWCRYJFQkVCRQJFAmy/d/24Pbg9uH24fbi9uL24/bj9uT25Pbl9uX25vbm9uf25/bo9uj2LvkQCRAJDwkPCQ4JDgkNCQ0JDAkMCQsJCwkKCQoJCQkJCQgJCAkHCQcJ+f/s9u327fbu9u727/bv9vD28Pbx9vH28vby9vP28/b09vX29fb29vb2AwkDCQIJAgkBCQEJAAkACf8I/wj+CP4I/Qj9CPwI/Aj7CPsI+gj6CDkC+vb69vv2+/b89vz2/fb99v72/vb/9v/2APcA9wH3AfcC9wL3A/cD97gG9Qj1CPQI9AjzCPMI8gjyCPEI8QjwCPAI7wjvCO4I7gjtCO0I7AhzBAf3B/cI9wj3CfcJ9wr3CvcL9wv3DPcM9w33DfcO9w73D/cP9xD3EPdzBOgI6AjnCOcI5gjmCOUI5QjkCOQI4wjjCOII4gjhCOEI4AjgCN8IpQYU9xX3FfcW9xb3F/cX9xj3GPcZ9xn3Gvca9xv3G/cc9xz3Hfcd9x73NQLbCNsI2gjaCNkI2QjYCNgI1wjXCNYI1gjVCNUI1AjUCNMI0wjSCNIIIfci9yL3I/cj9yT3JPcl9yX3Jvcm9yf3J/co9yj3Kfcp9yr3Kvcr9/3/zgjNCM0IzAjMCMsIywjKCMoIyQjJCMgIyAjHCMcIxgjGCMUIxQjECGH5L/cw9zD3Mfcx9zL3Mvcz9zP3NPc09zX3Nfc29zb3N/c39zj3OPfM/cEIwAjACL8Ivwi+CL4IvQi9CLwIvAi7CLsIugi6CLkIuQi4CLgItwib+zz3Pfc99z73Pvc/9z/3QPdA90H3QfdC90L3Q/dD90T3RPdF90X3ofu0CLMIswiyCLIIsQixCLAIsAivCK8IrgiuCK0IrQisCKwIqwirCKoIzf1K90r3S/dL90z3TPdN9033TvdO90/3T/dQ91D3UfdR91L3UvdT9375pgimCKUIpQikCKQIowijCKIIogihCKEIoAigCJ8InwieCJ4InQidCPn/V/dX91j3WPdZ91n3Wvda91v3W/dc91z3Xfdd9173Xvdf91/3YPdg95kImQiYCJgIlwiXCJYIlgiVCJUIlAiUCJMIkwiSCJIIkQiRCJAIkAgfAmT3Zfdl92b3Zvdn92f3aPdo92n3afdq92r3a/dr92v3bPds9233bfdoBosIiwiKCIoIiQiJCIkIiAiICIcIhwiGCIYIhQiFCIQIhAiDCIMIPgRx93L3cvdz93P3dPd093X3dfd293b3d/d393j3ePd593n3evd693v3PgR+CH4IfQh9CHwIfAh7CHsIegh6CHkIeQh4CHgIdwh3CHYIdgh1CFYGfvd/93/3gPeA94H3gfeC94L3g/eD94T3hPeF94X3hveG94f3h/eI9xoCcQhxCHAIcAhvCG8IbghuCG0IbQhsCGwIawhrCGoIaghpCGkIaAhoCIz3jPeN9433jveO94/3j/eQ95D3kfeR95L3kveT95P3lPeU95X3lff9/2QIYwhjCGIIYghhCGEIYAhgCF8IXwheCF4IXghdCF0IXAhcCFsIWwix+Zn3mvea95v3m/ec95z3nfed9573nvef95/3oPeg96H3ofei96L35v1XCFYIVghVCFUIVAhUCFMIUwhSCFIIUQhRCFAIUAhPCE8ITghOCE0I0Pun96f3qPeo96n3qfeq96r3q/er96z3rPet9633rveu96/3r/ew99f7SghJCEkISAhICEcIRwhGCEYIRQhFCEQIRAhDCEMIQghCCEEIQQhACOj9tPe097X3tfe297b3t/e397j3uPe597n3uve697v3u/e897z3vffN+TwIPAg7CDsIOgg6CDkIOQg4CDgINwg3CDYINgg1CDUINQg0CDQIMwj6/8H3wvfC98P3w/fE98T3xffF98b3xvfH98f3yPfI98n3yffK98r3y/cvCC8ILgguCC0ILQgsCCwIKwgrCCoIKggpCCkIKAgoCCcIJwgmCCYIBQLO98/3z/fQ99D30ffR99L30vfT99P31PfU99X31ffW99b31/fX99j3GQYhCCEIIQggCCAIHwgfCB4IHggdCB0IHAgcCBsIGwgaCBoIGQgZCAkE3Pfc99333ffe99733/ff9+D34Pfh9+H34vfi9+P34/fj9+T35Pfl9wkEFAgUCBMIEwgSCBIIEQgRCBAIEAgPCA8IDggOCA0IDQgNCAwIDAgHBun36ffq9+r36/fr9+z37Pft9+337vfu9+/37/fw9/D38ffx9/L38vcAAgcIBwgGCAYIBQgFCAQIBAgDCAMIAggCCAEIAQgACAAI/wf/B/4H/gf29/f39/f49/j3+ff59/r3+vf79/v3+/f89/z3/ff99/73/vf/9//3/f/6B/kH+Qf4B/gH+Af3B/cH9gf2B/UH9Qf0B/QH8wfzB/IH8gfxB/EHAfoE+AT4BfgF+Ab4BvgH+Af4CPgI+An4CfgK+Ar4C/gL+Az4DPgN+AH+7QfsB+wH6wfrB+oH6gfpB+kH6AfoB+cH5wfmB+YH5QflB+UH5AfkBwX8EfgS+BL4E/gT+BT4FPgV+BX4FfgW+Bb4F/gX+Bj4GPgZ+Bn4GvgM/OAH3wffB94H3gfdB90H3AfcB9sH2wfaB9oH2QfZB9gH2AfXB9cH1gcD/h74H/gf+CD4IPgh+CH4Ivgi+CP4I/gk+CT4Jfgl+Cb4Jvgn+Cf4HfrSB9IH0QfRB9AH0AfPB88HzwfOB84HzQfNB8wHzAfLB8sHygfKB8kH+v8s+Cz4Lfgt+C74Lvgu+C/4L/gw+DD4Mfgx+DL4Mvgz+DP4NPg0+DX4xQfFB8QHxAfDB8MHwgfCB8EHwQfAB8AHvwe/B74Hvge9B70HvQe8B+oBOfg5+Dr4Ovg7+Dv4PPg8+D34Pfg++D74P/g/+ED4QPhB+EH4QvhC+MkFuAe3B7cHtge2B7UHtQe0B7QHswezB7IHsgexB7EHsAewB68HrwfUA0b4R/hH+Ej4SPhI+En4SfhK+Er4S/hL+Ez4TPhN+E34TvhO+E/4T/jUA6oHqgepB6kHqAeoB6cHpwemB6YHpgelB6UHpAekB6MHoweiB6IHtwVT+FT4VPhV+FX4VvhW+Ff4V/hY+Fj4WfhZ+Fr4Wvhb+Fv4W/hc+Fz45QGdB50HnAecB5sHmweaB5oHmQeZB5gHmAeXB5cHlgeWB5UHlQeVB5QHYfhh+GL4Yvhi+GP4Y/hk+GT4Zfhl+Gb4Zvhn+Gf4aPho+Gn4afhq+P3/kAePB48HjweOB44HjQeNB4wHjAeLB4sHigeKB4kHiQeIB4gHhweHB1H6bvhv+G/4cPhw+HH4cfhy+HL4c/hz+HT4dPh0+HX4dfh2+Hb4d/gc/oMHggeCB4EHgQeAB4AHfwd/B34Hfgd+B30HfQd8B3wHewd7B3oHegc7/Hz4fPh8+H34ffh++H74f/h/+ID4gPiB+IH4gviC+IP4g/iE+IT4Qfx2B3UHdQd0B3QHcwdzB3IHcgdxB3EHcAdwB28HbwduB24HbgdtB20HHv6J+In4iviK+Iv4i/iM+Iz4jfiN+I74jviO+I/4j/iQ+JD4kfiR+G36aAdoB2cHZwdmB2YHZgdlB2UHZAdkB2MHYwdiB2IHYQdhB2AHYAdfB/r/lviW+Jf4l/iY+Jj4mfiZ+Jr4mvib+Jv4nPic+J34nfie+J74n/if+FsHWwdaB1oHWQdZB1gHWAdXB1cHVgdWB1YHVQdVB1QHVAdTB1MHUgfQAaP4pPik+KX4pfim+Kb4p/in+Kf4qPio+Kn4qfiq+Kr4q/ir+Kz4rPh6BU4HTQdNB0wHTAdLB0sHSgdKB0kHSQdIB0gHRwdHB0YHRgdGB0UHnwOw+LH4sfiy+LL4s/iz+LT4tPi1+LX4tvi2+Lf4t/i4+Lj4ufi5+Ln4nwNAB0AHPwc/Bz4HPgc+Bz0HPQc8BzwHOwc7BzoHOgc5BzkHOAc4B2gFvvi++L/4v/jA+MD4wfjB+MH4wvjC+MP4w/jE+MT4xfjF+Mb4xvjH+MsBMwczBzIHMgcxBzEHMAcwBy8HLwcuBy4HLgctBy0HLAcsBysHKwcqB8v4y/jM+Mz4zfjN+M74zvjP+M/40PjQ+NH40fjS+NL40vjT+NP41Pj9/yYHJQclByUHJAckByMHIwciByIHIQchByAHIAcfBx8HHgceBx4HHQeh+tn42fja+Nr42/jb+Nv43Pjc+N343fje+N743/jf+OD44Pjh+OH4Nv4ZBxgHGAcXBxcHFgcWBxYHFQcVBxQHFAcTBxMHEgcSBxEHEQcQBxAHcPzm+Ob45/jn+Oj46Pjp+On46vjq+Ov46/js+Oz47Pjt+O347vju+Hb8DAcLBwsHCgcKBwkHCQcIBwgHBwcHBwYHBgcGBwUHBQcEBwQHAwcDBzn+8/j0+PT49fj1+PX49vj2+Pf49/j4+Pj4+fj5+Pr4+vj7+Pv4/Pi8+v4G/gb9Bv0G/Qb8BvwG+wb7BvoG+gb5BvkG+Ab4BvcG9wb3BvYG9gb7/wD5AfkB+QL5AvkD+QP5BPkE+QX5BfkG+Qb5BvkH+Qf5CPkI+Qn5CfnxBvEG8AbwBu8G7wbuBu4G7QbtBu0G7AbsBusG6wbqBuoG6QbpBugGtgEO+Q75D/kP+RD5EPkQ+RH5EfkS+RL5E/kT+RT5FPkV+RX5FvkW+Rb5KgXkBuMG4wbiBuIG4QbhBuAG4AbfBt8G3gbeBt4G3QbdBtwG3AbbBmsDG/kb+Rz5HPkd+R35Hvke+R/5H/kg+SD5IPkh+SH5Ivki+SP5I/kk+WoD1gbWBtUG1QbVBtQG1AbTBtMG0gbSBtEG0QbQBtAGzwbPBs8GzgYZBSj5Kfkp+Sr5Kvkq+Sv5K/ks+Sz5Lfkt+S75Lvkv+S/5MPkw+TD5MfmxAckGyQbIBsgGxwbHBsYGxgbFBsUGxQbEBsQGwwbDBsIGwgbBBsEGwAY1+Tb5Nvk3+Tf5OPk4+Tn5Ofk6+Tr5Ovk7+Tv5PPk8+T35Pfk++T75/f+8BrwGuwa7BroGuga5BrkGuAa4BrcGtwa2BrYGtga1BrUGtAa0BrMG8fpD+UT5RPlE+UX5RflG+Ub5R/lH+Uj5SPlJ+Un5SvlK+Ur5S/lL+VH+rwauBq4GrQatBq0GrAasBqsGqwaqBqoGqQapBqgGqAanBqcGpwamBqX8UPlR+VH5UvlS+VP5U/lU+VT5VPlV+VX5VvlW+Vf5V/lY+Vj5Wfmr/KIGoQahBqAGoAafBp8GngaeBp0GnQadBpwGnAabBpsGmgaaBpkGmQZT/l75Xvle+V/5X/lg+WD5Yflh+WL5Yvlj+WP5ZPlk+WT5Zfll+Wb5DPuUBpQGlAaTBpMGkgaSBpEGkQaQBpAGjwaPBo4GjgaOBo0GjQaMBowG+/9r+Wv5bPls+W35bflu+W75bvlv+W/5cPlw+XH5cfly+XL5c/lz+XT5hwaHBoYGhgaFBoUGhAaEBoQGgwaDBoIGggaBBoEGgAaABn8GfwZ/BpwBePl5+Xn5efl6+Xr5e/l7+Xz5fPl9+X35fvl++X75f/l/+YD5gPmB+dsEegZ5BnkGeAZ4BncGdwZ2BnYGdQZ1BnUGdAZ0BnMGcwZyBnIGcQY2A4X5hvmG+Yf5h/mI+Yj5ifmJ+Yn5ivmK+Yv5i/mM+Yz5jfmN+Y75jvk1A2wGbAZrBmsGawZqBmoGaQZpBmgGaAZnBmcGZwZmBmYGZQZlBmQGygST+ZP5k/mU+ZT5lfmV+Zb5lvmX+Zf5mPmY+Zj5mfmZ+Zr5mvmb+Zv5lgFfBl8GXgZeBl0GXQZcBlwGXAZbBlsGWgZaBlkGWQZYBlgGWAZXBlcGoPmg+aH5ofmi+aL5o/mj+aP5pPmk+aX5pfmm+ab5p/mn+aj5qPmo+f7/UgZSBlEGUQZQBlAGTwZPBk4GTgZOBk0GTQZMBkwGSwZLBkoGSgZJBkH7rvmu+a75r/mv+bD5sPmx+bH5svmy+bL5s/mz+bT5tPm1+bX5tvls/kUGRAZEBkMGQwZDBkIGQgZBBkEGQAZABj8GPwY/Bj4GPgY9Bj0GPAbb/Lv5u/m8+bz5vfm9+b35vvm++b/5v/nA+cD5wfnB+cL5wvnC+cP54Pw4BjcGNwY2BjYGNQY1BjQGNAY0BjMGMwYyBjIGMQYxBjAGMAYwBi8Gbv7I+cj5yfnJ+cr5yvnL+cv5zPnM+cz5zfnN+c75zvnP+c/50PnQ+Vz7KgYqBioGKQYpBigGKAYnBicGJgYmBiYGJQYlBiQGJAYjBiMGIgYiBvv/1fnW+db51/nX+df52PnY+dn52fna+dr52/nb+dz53Pnc+d353fne+R0GHQYcBhwGGwYbBhsGGgYaBhkGGQYYBhgGFwYXBhcGFgYWBhUGFQaBAeL54/nj+eT55Pnl+eX55vnm+ef55/nn+ej56Pnp+en56vnq+ev56/mLBBAGDwYPBg4GDgYNBg0GDAYMBgwGCwYLBgoGCgYJBgkGCAYIBggGAQPw+fD58fnx+fL58vny+fP58/n0+fT59fn1+fb59vn2+ff59/n4+fj5AAMCBgIGAgYBBgEGAAYABv8F/wX+Bf4F/gX9Bf0F/AX8BfsF+wX6BXoE/fn9+f75/vn/+f/5APoA+gH6AfoB+gL6AvoD+gP6BPoE+gX6BfoF+nwB9QX1BfQF9AXzBfMF8wXyBfIF8QXxBfAF8AXvBe8F7wXuBe4F7QXtBQr6C/oL+gz6DPoM+g36DfoO+g76D/oP+hD6EPoQ+hH6EfoS+hL6E/r+/+gF6AXnBecF5gXmBeUF5QXlBeQF5AXjBeMF4gXiBeEF4QXhBeAF4AWQ+xj6GPoZ+hn6Gvoa+hv6G/ob+hz6HPod+h36Hvoe+h/6H/of+iD6hv7bBdoF2gXaBdkF2QXYBdgF1wXXBdYF1gXWBdUF1QXUBdQF0wXTBdIFEP0l+ib6Jvom+if6J/oo+ij6Kfop+ir6Kvoq+iv6K/os+iz6Lfot+hb9zgXNBc0F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lv2X/Zf9mP2Y/Zj9mf2Z/Zn9mv2a/WQCYwJjAmICYgJiAmECYQJhAmACYAJfAl8CXwJeAl4CXgJdAl0CXQKWAKD9of2h/aL9ov2i/aP9o/2j/aT9pP2k/aX9pf2m/ab9pv2n/af9p/3BAVYCVgJVAlUCVQJUAlQCUwJTAlMCUgJSAlICUQJRAlACUAJQAk8CJwGu/a79rv2v/a/9r/2w/bD9sf2x/bH9sv2y/bL9s/2z/bT9tP20/bX9JAFJAkgCSAJIAkcCRwJHAkYCRgJFAkUCRQJEAkQCRAJDAkMCQwJCArEBu/27/bz9vP28/b39vf29/b79vv2//b/9v/3A/cD9wP3B/cH9wv3C/Y4APAI7AjsCOgI6AjoCOQI5AjkCOAI4AjgCNwI3AjYCNgI2AjUCNQI1Asj9yf3J/cn9yv3K/cr9y/3L/cv9zP3M/c39zf3N/c79zv3O/c/9z/3//y4CLgIuAi0CLQItAiwCLAIrAisCKwIqAioCKgIpAikCKQIoAigCJwJg/tb91v3X/df91/3Y/dj92P3Z/dn92f3a/dr92/3b/dv93P3c/dz9d/8hAiECIAIgAiACHwIfAh8CHgIeAh4CHQIdAhwCHAIcAhsCGwIbAhoC8P7j/eP95P3k/eX95f3l/eb95v3m/ef95/3n/ej96P3p/en96f3q/fX+FAIUAhMCEwITAhICEgIRAhECEQIQAhACEAIPAg8CDwIOAg4CDQINAnv/8P3x/fH98f3y/fL98/3z/fP99P30/fT99f31/fX99v32/ff99/15/gcCBgIGAgYCBQIFAgUCBAIEAgQCAwIDAgICAgICAgECAQIBAgACAAL+//79/v3+/f/9//3//QD+AP4A/gH+Af4C/gL+Av4D/gP+A/4E/gT+BP76AfkB+QH5AfgB+AH3AfcB9wH2AfYB9gH1AfUB9QH0AfQB8wHzAfMBewAL/gv+DP4M/gz+Df4N/g3+Dv4O/g7+D/4P/hD+EP4Q/hH+Ef4R/hL+cQHsAewB6wHrAesB6gHqAeoB6QHpAegB6AHoAecB5wHnAeYB5gHmAfIAGP4Y/hn+Gf4a/hr+Gv4b/hv+G/4c/hz+HP4d/h3+Hv4e/h7+H/4f/u8A3wHeAd4B3gHdAd0B3QHcAdwB3AHbAdsB2wHaAdoB2QHZAdkB2AFiASX+Jv4m/ib+J/4n/ij+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S/9L/0z/TP9M/03/Tf9N/07/Tv9O/0//T/9P/1D/UP9Q/1H/gwCuAK4ArgCtAK0ArQCsAKwArACrAKsAqwCqAKoAqgCpAKkAqQCoAFQAV/9Y/1j/WP9Z/1n/Wf9a/1r/Wv9b/1v/W/9c/1z/XP9d/13/Xf9e/1EAoQChAKAAoACgAJ8AnwCfAJ4AngCeAJ0AnQCdAJwAnACcAJsAmwB0AGX/Zf9l/2b/Zv9m/2f/Z/9n/2j/aP9o/2n/af9p/2r/av9q/2v/a/8lAJQAkwCTAJMAkgCSAJIAkQCRAJEAkACQAJAAjwCPAI8AjgCOAI4AjQBy/3L/c/9z/3P/dP90/3T/df91/3X/dv92/3b/d/93/3f/eP94/3j/AACHAIYAhgCGAIUAhQCFAIQAhACEAIMAgwCDAIIAggCCAIEAgQCBAIAAn/+A/4D/gP+B/4H/gf+C/4L/gv+D/4P/g/+E/4T/hP+F/4X/hf+G/+H/eQB5AHkAeAB4AHgAdwB3AHcAdgB2AHYAdQB1AHUAdAB0AHQAcwBzAMb/jf+N/43/jv+O/47/j/+P/4//kP+Q/5D/kf+R/5H/kv+S/5L/k//J/2wAbABsAGsAawBrAGoAagBqAGkAaQBpAGgAaABoAGcAZwBnAGYAZgDm/5r/mv+b/5v/m/+c/5z/nP+d/53/nf+e/57/nv+f/5//n/+g/6D/uP9fAF8AXgBeAF4AXQBdAF0AXABcAFwAWwBbAFsAWgBaAFoAWQBZAFkAAACn/6j/qP+o/6n/qf+p/6r/qv+q/6v/q/+r/6z/rP+s/63/rf+t/67/UgBRAFEAUQBQAFAAUABPAE8ATwBPAE4ATgBOAE0ATQBNAEwATABMABMAtf+1/7X/tv+2/7b/t/+3/7f/uP+4/7j/uf+5/7n/uv+6/7r/u/+7/zMARABEAEQAQwBDAEMAQgBCAEIAQQBBAEEAQABAAEAAPwA/AD8APgAfAML/wv/D/8P/w//E/8T/xP/F/8X/xf/F/8b/xv/G/8f/x//H/8j/yP8cADcANwA2ADYANgA1ADUANQA0ADQANAAzADMAMwAyADIAMgAyADEAJQDP/8//0P/Q/9D/0f/R/9H/0v/S/9L/0//T/9P/1P/U/9T/1f/V/9X/CgAqACoAKQApACkAKAAoACgAJwAnACcAJgAmACYAJQAlACUAJAAkACQA3P/d/93/3f/e/97/3v/f/9//3//g/+D/4P/h/+H/4f/i/+L/4v/j/wAAHQAcABwAHAAbABsAGwAaABoAGgAZABkAGQAYABgAGAAXABcAFwAXAO//6v/q/+v/6//r/+z/7P/s/+3/7f/t/+7/7v/u/+//7//v//D/8P/8/w8ADwAPAA4ADgAOAA4ADQANAA0ADAAMAAwACwALAAsACgAKAAoACQD7//f/+P/4//j/+f/5//n/+v/6//r/+//7//v/+//8//z//P/9//3///8CAAIAAgABAAEAAQAAAAAAAAA='; +const mp3DataUri = + 'data:audio/mpeg;base64,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'; + +class SourcesTab extends StatefulWidget { + final AudioPlayer player; + + const SourcesTab({ + required this.player, + super.key, + }); + + @override + State createState() => _SourcesTabState(); +} + +class _SourcesTabState extends State + with AutomaticKeepAliveClientMixin { + AudioPlayer get player => widget.player; + + final List sourceWidgets = []; + + Future _setSource(Source source) async { + await player.setSource(source); + toast( + 'Completed setting source.', + textKey: const Key('toast-set-source'), + ); + } + + Future _play(Source source) async { + await player.stop(); + await player.play(source); + toast( + 'Set and playing source.', + textKey: const Key('toast-set-play'), + ); + } + + Future _removeSourceWidget(Widget sourceWidget) async { + setState(() { + sourceWidgets.remove(sourceWidget); + }); + toast('Source removed.'); + } + + Widget _createSourceTile({ + required String title, + required String subtitle, + required Source source, + Key? setSourceKey, + Color? buttonColor, + Key? playKey, + }) => + _SourceTile( + setSource: () => _setSource(source), + play: () => _play(source), + removeSource: _removeSourceWidget, + title: title, + subtitle: subtitle, + setSourceKey: setSourceKey, + playKey: playKey, + buttonColor: buttonColor, + ); + + Future _setSourceBytesAsset( + Future Function(Source) fun, { + required String asset, + String? mimeType, + }) async { + final bytes = await AudioCache.instance.loadAsBytes(asset); + await fun(BytesSource(bytes, mimeType: mimeType)); + } + + Future _setSourceBytesRemote( + Future Function(Source) fun, { + required String url, + String? mimeType, + }) async { + final bytes = await http.readBytes(Uri.parse(url)); + await fun(BytesSource(bytes, mimeType: mimeType)); + } + + @override + void initState() { + super.initState(); + sourceWidgets.addAll( + [ + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-wav-1'), + title: 'Remote URL WAV 1', + subtitle: 'coins.wav', + source: UrlSource(wavUrl1), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-wav-2'), + title: 'Remote URL WAV 2', + subtitle: 'laser.wav', + source: UrlSource(wavUrl2), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-mp3-1'), + title: 'Remote URL MP3 1 (VBR)', + subtitle: 'ambient_c_motion.mp3', + source: UrlSource(mp3Url1), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-mp3-2'), + title: 'Remote URL MP3 2', + subtitle: 'nasa_on_a_mission.mp3', + source: UrlSource(mp3Url2), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-m3u8'), + title: 'Remote URL M3U8', + subtitle: 'BBC stream', + source: UrlSource(m3u8StreamUrl), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-mpga'), + title: 'Remote URL MPGA', + subtitle: 'Times stream', + source: UrlSource(mpgaStreamUrl), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-data-wav'), + title: 'Data URI WAV', + subtitle: 'coins.wav', + source: UrlSource(wavDataUri), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-data-mp3'), + title: 'Data URI MP3', + subtitle: 'coins.mp3', + source: UrlSource(mp3DataUri), + ), + _createSourceTile( + setSourceKey: const Key('setSource-asset-wav'), + title: 'Asset WAV', + subtitle: 'laser.wav', + source: AssetSource(wavAsset2), + ), + _createSourceTile( + setSourceKey: const Key('setSource-asset-mp3'), + title: 'Asset MP3', + subtitle: 'nasa.mp3', + source: AssetSource(mp3Asset), + ), + _SourceTile( + setSource: () => _setSourceBytesAsset( + _setSource, + asset: wavAsset2, + mimeType: 'audio/wav', + ), + setSourceKey: const Key('setSource-bytes-local'), + play: () => _setSourceBytesAsset( + _play, + asset: wavAsset2, + mimeType: 'audio/wav', + ), + removeSource: _removeSourceWidget, + title: 'Bytes - Local', + subtitle: 'laser.wav', + ), + _SourceTile( + setSource: () => _setSourceBytesRemote( + _setSource, + url: mp3Url1, + mimeType: 'audio/mpeg', + ), + setSourceKey: const Key('setSource-bytes-remote'), + play: () => _setSourceBytesRemote( + _play, + url: mp3Url1, + mimeType: 'audio/mpeg', + ), + removeSource: _removeSourceWidget, + title: 'Bytes - Remote', + subtitle: 'ambient.mp3', + ), + _createSourceTile( + setSourceKey: const Key('setSource-asset-invalid'), + title: 'Invalid Asset', + subtitle: 'invalid.txt', + source: AssetSource(invalidAsset), + buttonColor: Colors.red, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Stack( + alignment: Alignment.bottomCenter, + children: [ + TabContent( + children: sourceWidgets + .expand((element) => [element, const Divider()]) + .toList(), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { + dialog( + _SourceDialog( + onAdd: (Source source, String path) { + setState(() { + sourceWidgets.add( + _createSourceTile( + title: source.runtimeType.toString(), + subtitle: path, + source: source, + ), + ); + }); + }, + ), + ); + }, + ), + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} + +class _SourceTile extends StatelessWidget { + final void Function() setSource; + final void Function() play; + final void Function(Widget sourceWidget) removeSource; + final String title; + final String? subtitle; + final Key? setSourceKey; + final Key? playKey; + final Color? buttonColor; + + const _SourceTile({ + required this.setSource, + required this.play, + required this.removeSource, + required this.title, + this.subtitle, + this.setSourceKey, + this.playKey, + this.buttonColor, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Set Source', + key: setSourceKey, + onPressed: setSource, + icon: const Icon(Icons.upload_file), + color: buttonColor ?? Theme.of(context).primaryColor, + ), + IconButton( + key: playKey, + tooltip: 'Play', + onPressed: play, + icon: const Icon(Icons.play_arrow), + color: buttonColor ?? Theme.of(context).primaryColor, + ), + IconButton( + tooltip: 'Remove', + onPressed: () => removeSource(this), + icon: const Icon(Icons.delete), + color: buttonColor ?? Theme.of(context).primaryColor, + ), + ], + ), + ); + } +} + +class _SourceDialog extends StatefulWidget { + final void Function(Source source, String path) onAdd; + + const _SourceDialog({required this.onAdd}); + + @override + State<_SourceDialog> createState() => _SourceDialogState(); +} + +class _SourceDialogState extends State<_SourceDialog> { + Type sourceType = UrlSource; + String path = ''; + + final Map assetsList = {'': 'Nothing selected'}; + + @override + void initState() { + super.initState(); + + AssetManifest.loadFromAssetBundle(rootBundle).then((assetManifest) { + setState(() { + assetsList.addAll( + assetManifest + .listAssets() + .map((e) => e.replaceFirst('assets/', '')) + .toList() + .asMap() + .map((key, value) => MapEntry(value, value)), + ); + }); + }); + } + + Widget _buildSourceValue() { + switch (sourceType) { + case AssetSource: + return Row( + children: [ + const Text('Asset path'), + const SizedBox(width: 16), + Expanded( + child: CustomDropDown( + options: assetsList, + selected: path, + onChange: (value) => setState(() { + path = value ?? ''; + }), + ), + ), + ], + ); + case BytesSource: + case DeviceFileSource: + return Row( + children: [ + const Text('Device File path'), + const SizedBox(width: 16), + Expanded(child: Text(path)), + TextButton.icon( + onPressed: () async { + final result = await FilePicker.platform.pickFiles(); + final path = result?.files.single.path; + if (path != null) { + setState(() { + this.path = path; + }); + } + }, + icon: const Icon(Icons.file_open), + label: const Text('Browse'), + ), + ], + ); + default: + return Row( + children: [ + const Text('URL'), + const SizedBox(width: 16), + Expanded( + child: TextField( + decoration: const InputDecoration( + hintText: 'https://example.com/myFile.wav', + ), + onChanged: (String? url) => path = url ?? '', + ), + ), + ], + ); + } + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + LabeledDropDown( + label: 'Source type', + options: const { + AssetSource: 'Asset', + DeviceFileSource: 'Device File', + UrlSource: 'Url', + BytesSource: 'Byte Array', + }, + selected: sourceType, + onChange: (Type? value) { + setState(() { + if (value != null) { + sourceType = value; + } + }); + }, + ), + ListTile(title: _buildSourceValue()), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Btn( + onPressed: () async { + switch (sourceType) { + case BytesSource: + widget.onAdd( + BytesSource(await File(path).readAsBytes()), + path, + ); + case AssetSource: + widget.onAdd(AssetSource(path), path); + case DeviceFileSource: + widget.onAdd(DeviceFileSource(path), path); + default: + widget.onAdd(UrlSource(path), path); + } + Navigator.of(context).pop(); + }, + txt: 'Add', + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + ], + ), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/tabs/streams.dart b/packages/audioplayers/example/lib/tabs/streams.dart new file mode 100644 index 0000000..db776dc --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/streams.dart @@ -0,0 +1,28 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/components/player_widget.dart'; +import 'package:audioplayers_elinux_example/components/properties_widget.dart'; +import 'package:audioplayers_elinux_example/components/stream_widget.dart'; +import 'package:audioplayers_elinux_example/components/tab_content.dart'; +import 'package:flutter/material.dart'; + +class StreamsTab extends StatelessWidget { + final AudioPlayer player; + + const StreamsTab({ + required this.player, + super.key, + }); + + @override + Widget build(BuildContext context) { + return TabContent( + children: [ + PlayerWidget(player: player), + const Divider(), + StreamWidget(player: player), + const Divider(), + PropertiesWidget(player: player), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/utils.dart b/packages/audioplayers/example/lib/utils.dart new file mode 100644 index 0000000..a6e5bc1 --- /dev/null +++ b/packages/audioplayers/example/lib/utils.dart @@ -0,0 +1,42 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_elinux_example/components/dlg.dart'; +import 'package:flutter/material.dart'; + +extension StateExt on State { + void toast(String message, {Key? textKey}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message, key: textKey), + duration: Duration(milliseconds: message.length * 25), + ), + ); + } + + void simpleDialog(String message, [String action = 'Ok']) { + showDialog( + context: context, + builder: (_) { + return SimpleDlg(message: message, action: action); + }, + ); + } + + void dialog(Widget child) { + showDialog( + context: context, + builder: (_) { + return Dlg(child: child); + }, + ); + } +} + +extension PlayerStateIcon on PlayerState { + IconData getIcon() { + return this == PlayerState.playing + ? Icons.play_arrow + : (this == PlayerState.paused + ? Icons.pause + : (this == PlayerState.stopped ? Icons.stop : Icons.stop_circle)); + } +} diff --git a/packages/audioplayers/example/pubspec.yaml b/packages/audioplayers/example/pubspec.yaml new file mode 100644 index 0000000..6bb63ba --- /dev/null +++ b/packages/audioplayers/example/pubspec.yaml @@ -0,0 +1,28 @@ +name: audioplayers_elinux_example +description: Demonstrates how to use the audioplayers plugin. +publish_to: none + +dependencies: + audioplayers: ^6.0.0 + audioplayers_elinux: + path: ../ + collection: ^1.16.0 + file_picker: ^6.1.1 + flutter: + sdk: flutter + http: ^1.0.0 + path_provider_elinux: + path: ../../path_provider + provider: ^6.0.5 + +dev_dependencies: + +flutter: + uses-material-design: true + + assets: + - assets/ + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.13.0' diff --git a/packages/audioplayers/pubspec.yaml b/packages/audioplayers/pubspec.yaml new file mode 100644 index 0000000..f30aa6f --- /dev/null +++ b/packages/audioplayers/pubspec.yaml @@ -0,0 +1,20 @@ +name: audioplayers_elinux +description: Flutter plugin for playing audio with other Flutter widgets on Embedded Linux. +homepage: https://github.com/sony/flutter-elinux-plugins/ +repository: https://github.com/sony/flutter-elinux-plugins/tree/main/packages/auidoplayers +version: 0.1.0 + +flutter: + plugin: + platforms: + elinux: + pluginClass: AudioplayersElinuxPlugin + +dependencies: + audioplayers_platform_interface: ^7.0.0 + flutter: + sdk: flutter + +environment: + sdk: ">=2.18.0 <4.0.0" + flutter: ">=3.7.0"