Эволюция или делаем базу для роботележки на ARDUINO платформе, а сенсоры и видео гоним на компьютер через смартфон

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
Для уважаемых читателей GeekTimes очередная (четвёртая) долгожданная статья о том, что будет, если снова замешать ардуинку, ESP8266, WI-FI, приправить смартфоном на Android и посыпать сверх JAVA приложением.

Речь у нас пойдет про роботелегу из позапрошлой статьи, которой пришла пора хотя бы немного поумнеть.

image

Кому интересно, добро пожаловать под кат.

Если вам не сильно интересно читать старые статьи, то коротко — суть там была в том, что для управления обычной четырех-колесной тележкой на платформе Arduino, к ней был присобачен разработанный мною беспроводной UART мост на основе всем известного модуля ESP8266. Также для удобства (а вообще это и была главная цель) при помощи той же ESP, я написал программатор для ардуинки, который позволяет удаленно её же прошивать.

image

То есть тележка где-то далеко (но в пределах вашей WI-FI сети) ездиет (да, люблю я так писать это слово), шлёт данные и получает команды, а если надо по приказу может и программу сменить у себя в AVR микроконтроллере. Соответственно была накалякана программка на JAVA для ПК, запустив которую можно было наслаждаться управлением и получать примитивную телеметрию в виде пройденного пути (геркон и магнит на колесе).

image

Далее, я успешно экспериментировал в следующей статье с управлением тележкой при помощи смартфона — кнопками, наклонами и даже голосом. Но когда телега уезжала в соседнюю комнату, то вернуть её (в отличие от кота) не мог даже голос. Она ездила там, стукалась о стены и мебель, запутывалась в проводах, но кроме информации о пройденном пути не присылала ничего.

Поэтому сразу же возникла мысль обеспечить будущего Терминатора органами чувств. Один из самых простых вариантов для этого — использование эхолокатора.

image

Алгоритм работы до безобразия простой, одним фронтом запускаем датчик и одновременно какой-либо счетчик микроконтроллера. HC-SR04 начинает пулять ультразвуком вдаль. Ответный сигнал от датчика по другому проводу сигнализирует о конце измерения расстояния, а временной интервал между запуском и ответом пропорционален померенной дистанции. Соответственно в этот момент мы тормозим счётчик и смотрим сколько в нём натикало.

Точность получается примерно до сантиметра, а дальность метров до двух. Не любит ворсистые и шерстяные поверхности (к примеру кота), где безвозвратно тонут любые эхо сигналы.

Зона поражения угол обзора у HC-SR04 невелик, поэтому чтобы знать, что происходит впереди в рамках угла хотя бы градусов девяноста желательно сделать следующее:

  1. прикрутить датчик к серво-машинке и смотреть им в разные стороны
  2. поставить несколько датчиков.

Вначале я реализовал первый вариант, поставив эхолокатор на дешевый сервопривод SG90 и тележка превратилась в марсоход. Уж очень много времени требовалось, чтобы провести хотя бы три измерения, поэтому в основном телега стояла, вращая сервой, затем осторожно двигалась вперед, но не очень далеко (а вдруг сбоку появилось препятствие), и снова несколько секунд ощупывала пространство перед собой. Все-таки звук это вам не свет.

Поэтому, не мудрствуя лукаво, я поставил на неё сразу три эхолокатора. Телега приобрела хтоничный паучий вид, перестала тупить перед препятствиями, и начала обруливать их на ходу. Но мозгов в итоге хватало только на то, чтобы просто не застревать в дружелюбной среде. Надо было двигаться дальше — к автономности и прогрессу. А здесь без разных органов чувств, как скажет вам даже червь нематода, не обойтись.


Далее, обычно энтузиасты начинают лепить на свои создания разные новые датчики типа гироскопов-акселерометров-магнитометров и даже датчиков ОГНЯ (всё то, что производят в миллионных количествах для Ардуино безымянные китайцы). И я тоже чуть не пустился было по этой скользкой дорожке, но вовремя одумался. А сделал я это вот почему. В самой дальней перспективе, роботележка должна была получить зрение в виде камеры и к тому же разбираться в том, что видит. Но AVR микроконтроллер c платы Ардуино скажет вам «до свиданья» ещё на этапе получения видео, не говоря уже об его обработке. И внезапно мой взгляд упал на немолодой уже и потрёпанный жизнью смартфон GALAXY S7.

Такая вычислительная мощь, восемь ядер, 4 гига памяти, две камеры, выход в сеть, что еще нужно для превращения обезьяны в человека?

А всего лишь нужна небольшая конструкция для того, чтобы наш смартфон держался на тележке и чтобы его легко можно было поставить и снять.

image

Дальше я полез на сайт разработчиков Android, чтобы узнать какими еще возможностями может одарить нас обычный смартфон. Оказалось, что не малыми. Теоретически вы можете

получить доступ к следующим датчикам-сенсорам.
TYPE_ACCELEROMETER

TYPE_AMBIENT_TEMPERATURE

TYPE_GAME_ROTATION_VECTOR

GEOMAGNETIC_ROTATION_VECTOR

TYPE_GRAVITY

TYPE_GYROSCOPE

TYPE_GYROSCOPE_UNCALIBRATED

TYPE_HEART_BEAT

TYPE_HEART_RATE

TYPE_LIGHT

TYPE_LINEAR_ACCELERATION

TYPE_LOW_LATENCY_OFFBODY_DETECT

TYPE_MAGNETIC_FIELD

TYPE_MAGNETIC_FIELD_UNCALIBRATED

TYPE_MOTION_DETECT

TYPE_ORIENTATION

TYPE_POSE_6DOF

TYPE_PRESSURE

TYPE_PROXIMITY

TYPE_RELATIVE_HUMIDITY

TYPE_ROTATION_VECTOR

TYPE_SIGNIFICANT_MOTION

TYPE_STATIONARY_DETECT

TYPE_STEP_COUNTER

TYPE_STEP_DETECTOR


Как говорится, чего тут только нет! И действительно, в отношении конкретного GALAXY S7 не было много чего. Например, датчика влажности. И окружающей температуры (хотя я и понимал, что находясь внутри корпуса, он будет показывать температуру самого смартфона). Зато сенсоры давления и освещенности присутствовали. Уже не говоря о гироскопах-акселерометрах, пользуя которые, можно легко определить свое положение в пространстве.

В итоге созрело решение, пусть смартфон получает и обрабатывает всю информацию верхнего уровня — видео и все эти разнообразные датчики. А платформа Arduino будет ответственна, так сказать, за бессознательное — за всё то, что уже работает и не требует переделки, все эти моторчики, эхолокаторы, герконы и прочее.

Поскольку непосредственно на смартфоне отлаживать работу программы затруднительно, даже с UDB, я решил, что пусть всё это дело пока передается на нормальный персональный компьютер и там обрабатывается. А уж как-нибудь потом, когда будет рабочая версия, вернём мозги обратно на телегу. Надо начинать с малого, да и вообще интересно же посмотреть на передачу видео с бешеной телеги.

Данные с сенсоров можно переслать простой строкой, через примитивный клиент-сервер, с этим вообще проблем нет. Но вот с передачей видео, сразу же возникли затруднения. Вообще, мне нужна был потоковая трансляция реального времени с камеры смартфона в окошко приложения на компьютере. Это пока. В будущем на эту картинку в окошке мог глазеть бы не только я, но и какая-нибудь система по распознаванию образов. Например JAVA OpenCV. А может даже нейронная сеть из облака :D. Не знаю, до этого этапа ещё очень далеко. Но вот видеть мир «глазом» роботележки мне бы хотелось.

Всем известны многочисленные приложения типа «мобильной камеры» из магазина Гугла, где вы ловите видеопоток с камеры смартфона, открыв браузер с нужным IP на компьютере. Поэтому я сначала подумал, что самому реализовать трансляцию с моего GALAXY будет несложно (что было неслабым заблуждением), поэтому сначала надо проверить как будет с её приёмом на компьютере, учитывая то, что кое-как писать я умею только на JAVA.

Как выяснилось, с воспроизведением видео на JAVA, мягко говоря, не очень хорошо. Когда-то очень давно в 1997 выходил так называемый Java Media Framework — библиотека, облегчающая разработку программ, работающих с аудио и видео от самих создателей JAVA. Но, где-то после 2003 на неё положили большой болт и с тех пор прошло уже заметьте 15 лет. После некоторых экспериментов, мне удалось запустить в окошке один файл не помню уже какой (кажется AVI), но выглядело это зрелище изрядно убого. Файлы с другими расширениями, вообще не желали запускаться, в крайнем случае шла одна звуковая дорожка.

В Интернете я нашел ещё два альтернативных проекта для работы с видео: Xuggler и Сaprica VLCj. Первый проект был привлекателен по своим возможностям, но тоже сдох уже довольно давно, а вот второй оказался вполне живым и интересен по самой своей идее. Ребята взяли и прикрутили к JAVA всем известный популярный медиа плеер VLC. То есть Сaprica не юзает самописные кодеки, а пользуется уже готовыми от профессионалов. С ней у вас проиграется любой файл. Мудрое решение, но главное состоит в том, чтобы у вас на компьютере уже был установлен этот самый VLC плеер. Ну, а у кого его нет? Единственный нюанс, правда, в том, чтобы у вас совпадали разрядности плеера и JAVA. Я к примеру уже потом с удивлением, выяснил, что у меня на компьютере всё еще проживал 32-разрядный VLC, в отличие от 64-разрядной JAVA. И полдня жизни было потеряно зря.

На своем сайте разработчики от Сaprica сулят пользователям много чего. И все форматы файлов и запуск в нескольких окнах в JAVA приложении, проигрывание видео с You-Tube, захват «живого» видео потока и так далее. Но суровая реальность расставила всё на свои места. Нет, с файлами не обманули — играется всё. Но вот видео с ютуба уже не хочет. Я сначала не мог понять почему, но потом увидел в логе надпись, что как-то, где-то невозможно запустить некий lua-скрипт и тут же припомнил, что:
This 'screen-scraping' of the YouTube web page is brittle — if YouTube change the structure of their web-pages then VLC will sometimes fail to find the streaming URL, when this happens you have to wait for a developer to provide a new LUA script and wait for a new version of VLC to be released.
Короче, видимо Ютуб поменял структуру своей web страницы и мне надо ждать нового релиза. С другой стороны, мне же необходимо «живая» видео трансляция, а не проигрывание файла с сайта. То есть, даже, если бы lua-скрипт работал, сильно бы это мне не помогло.

А вот обещанного стриминга я вообще не нашёл, хотя и было написано что:

Network streaming server (e.g. a network radio station or a video on demand server);
Network streaming client;
Может это хотелки, а может есть в коммерческой версии, трудно сказать.

Но файлы проигрываются, повторюсь, без каких-либо нареканий. Например, можно творить, вот такое непотребство


Сама инсталляция пакета не представляет затруднений и даже подробно описана, к примеру, здесь. Правда, я как-то коряво прописал переменные окружений и теперь мой VLC запускается в первый раз с задержкой секунд в десять, но зато потом сохраняет, что надо в кэше и в дальнейшем в текущей сессии запускаетcя уже без пауз.

Потом в своем JAVA проекте вы прописываете необходимые зависимости и можете начинать клепать медиа проигрыватели в JAVA окошках работать.

Обозначив таким образом примерный план работ на стороне персонального компьютера, я вернулся к источнику видео данных, своему ANDROID смартфону.

Но здесь меня ждало ещё одно довольно большое разочарование. Прошерстив довольно много сайтов и даже онлайн руководство по ANDROID, я обнаружил, что организовать стриминг в режиме реального времени штатными средствами нельзя. Как и в Caprica, можно только читать уже записанные файлы. То есть, камера включилась, MEDIA RECORDER начал запись, когда нужно остановил. А доступ к данным (и их пересылке )мы можем получить только после остановки.

Подтверждение своим выводам я нашёл в одной древней статье. Там правда были намеки, что когда-то кому-то удавалось обмануть Android, чтобы он думал, что пишет в файл, а на самом деле ему подсовывали буфер. Но в любом случае, как уже было выяснено ранее, я не мог поймать такой видео поток на стороне ПК в JAVA приложение.

Поэтому было принято простое, дубовое, временное (хочу подчеркнуть) решение — слать видео поток с камеры кусочками по две секунды. Не марсоход, конечно, но луноход уже получается вполне.

Остановившись на этом решении я принялся осваивать классы CAMERA и MEDIA RECORDER. В сети лежит довольно много примеров кода для запуска камеры и записи видео файлов, но у меня почему-то не работал ни один из них. Ни на эмуляторе, ни на реальном девайсе. Выяснилось, что причина лежит в permissions, то есть в разрешениях. Оказывается, примеры кода писались в те времена, когда Android был ещё свободен и программист, мог делать всё что хотел, если писал все нужные разрешения в манифесте. А вот моя текущая версия OC этого делать не позволяла. Сначала надо было дать пользователю разрешение писать файл и включать камеру непосредственно после запуска приложения. Это обошлось мне в лишнюю активность, то есть в Activity.

Класс MainActivity.java
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.hardware.Camera;
import android.media.MediaRecorder;
import android.os.AsyncTask;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.Toast;
import java.io.File;
public class MainActivity extends AppCompatActivity {
    Camera camera;
    MediaRecorder mediaRecorder;
    public static MainActivity m;
    public  static boolean Camera_granted;
    File videoFile;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        if ((ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED)
                ||(ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) )
        //ask for authorisation
        {  ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.CAMERA,Manifest.permission.WRITE_EXTERNAL_STORAGE}, 50);
        }
        if ((ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED)&
                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED))
        { Toast.makeText(MainActivity.this,"ждемс", Toast.LENGTH_SHORT).show();}
                m=this;
        MyTask mt = new MyTask();
           mt.execute();
    }
}
class MyTask extends AsyncTask<Void, Void, Void> {
    @Override
    protected void onPreExecute() {
        super.onPreExecute();
    }
    @Override
    protected Void doInBackground(Void... params) {
        boolean ready = false;
         while(!ready)
        {
            if ((ContextCompat.checkSelfPermission(MainActivity.m, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)&
                    (ContextCompat.checkSelfPermission(MainActivity.m, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED))
            {
                System.out.println("готов бля");
                ready=true;
            }
            }
        return null;
    }
    @Override
    protected void onPostExecute(Void result) {
 Toast.makeText(MainActivity.m,"готов", Toast.LENGTH_SHORT).show();
        Intent intent=new Intent(MainActivity.m,CameraActivity.class);
        //Запускаем его при нажатии:
        MainActivity.m.startActivity(intent);
    }
}



После этого дела пошли на лад и появился следующий рабочий код. Вторая активити класс Camera_Activity отвечает за работу с камерой и запись видео файлов. Класс Http_server за пересылку (название, конечно, неправильное, но так исторически получилось). Код простой, везде где нужно есть пояснения.



Целиком всё валяется на Гитхабе. Ссылка.

Camera_Activity
	
import android.hardware.Camera;
import android.media.MediaRecorder;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.ServerSocket;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.content.Context;
import android.hardware.Sensor;
import static android.hardware.Camera.getNumberOfCameras;
import java.io.BufferedOutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;


import java.net.Socket;


/**
 * Created by m on 01.02.2019.
 */
public class CameraActivity extends AppCompatActivity implements SensorEventListener {
    SurfaceView surfaceView;
    TextView mTextView;
    Button mStart;
    Button mStop;
    Camera camera;
    MediaRecorder mediaRecorder;
    public static ServerSocket ss;
    public static ServerSocket ss2;
    public static MainActivity m;
    public static volatile boolean stopCamera=true;
    public static int count=1;
    public static File videoFile1;
    public static File videoFile2;
    public static File videoFile3;
    public static volatile byte[] data;
    public SensorManager mSensorManager;
    public Sensor mAxeleration, mLight,mRotation,mHumidity,mPressure,mTemperature;
    public int ax;
    public int ay;
    public int az;
    public double light;
    public int x;
    public int y;
    public int z;
    public double hum;
    public double press;
    public double tempr;
    public static String Sensors;
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mSensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); // Получаем менеджер сенсоров
        mAxeleration = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); // Получаем датчик положения
        mSensorManager.registerListener(this, mAxeleration, SensorManager.SENSOR_DELAY_NORMAL);
        mLight = mSensorManager.getDefaultSensor(Sensor.TYPE_LIGHT);
        mSensorManager.registerListener(this, mLight, SensorManager.SENSOR_DELAY_NORMAL);
        mRotation = mSensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION);
        mSensorManager.registerListener(this, mRotation, SensorManager.SENSOR_DELAY_NORMAL);
        mHumidity = mSensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION);
        mSensorManager.registerListener(this, mHumidity, SensorManager.SENSOR_DELAY_NORMAL);
        mPressure = mSensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE);
        mSensorManager.registerListener(this, mPressure, SensorManager.SENSOR_DELAY_NORMAL);
        mTemperature = mSensorManager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE);
        mSensorManager.registerListener(this, mTemperature, SensorManager.SENSOR_DELAY_NORMAL);
        setContentView(R.layout.camera);
        // videoFile = new File(Environment.getExternalStorageDirectory() + File.separator+ Environment.DIRECTORY_DCIM + File.separator + "test.3gp");
        videoFile1 = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), "test1.3gp");
        videoFile2 = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), "test2.3gp");
        videoFile3 = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), "test3.3gp");
       // videoFile4 = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), "testOUT.3gp");
        //  File file = new File(Environment.getExternalStorageDirectory(),VIDEO_PATH_NAME);// не работает
        // "touch" the file
        try {
            videoFile1.createNewFile();
            videoFile2.createNewFile();
            videoFile3.createNewFile();
        } catch (IOException e) {
        }
        mStart = (Button) findViewById(R.id.btnStartRecord);
        mStop = (Button) findViewById(R.id.btnStopRecord);
        surfaceView = (SurfaceView) findViewById(R.id.surfaceView);
        mTextView = (TextView) findViewById(R.id.textView);
        SurfaceHolder holder = surfaceView.getHolder();
        holder.addCallback(new SurfaceHolder.Callback() {
            @Override
            public void surfaceCreated(SurfaceHolder holder) {
                try {
                    camera.setPreviewDisplay(holder);
                    camera.startPreview();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            @Override
            public void surfaceChanged(SurfaceHolder holder, int format,
                                       int width, int height) {
            }
            @Override
            public void surfaceDestroyed(SurfaceHolder holder) {
            }
        });
        mStart.setOnClickListener(new View.OnClickListener()  {
                                     public  void  onClick(View v){
                                            System.out.println("нажали старт запись");
// заблокировать после нажатия
                                            WriteVideo WV = new WriteVideo();
                                            WV.start();
                                            mStart.setClickable(false);
                                            mStop.setClickable(true);
                                            Http_server.File_is_sent=true;
                                         new ServerCreation().execute();// запускаем сервер для отправки файлов
                                        }
                                    }
        );
        mStop.setOnClickListener(new View.OnClickListener()  {
                                        public  void  onClick(View v){
                                            stopCamera = false;
                                            //  mTextView.setText("нажали стоп");
                                            releaseMediaRecorder();
                                            releaseCamera();
                                            mStart.setClickable(true);
                                            mStop.setClickable(false);
                                            System.out.println("нажали стоп запись");
                                            Http_server.File_is_sent=true;
                                        }
                                    }
        );
    }
    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) { //Изменение точности показаний датчика
    }
    @Override
    public void onSensorChanged(SensorEvent event) { //Изменение показаний датчиков
        switch (event.sensor.getType()) {
            case Sensor.TYPE_ACCELEROMETER:
                ax = (int)(event.values[0] * 9); //крен +влево - вправо
                az = (int)(event.values[2] * 9);// тангаж + вперед  - назад
          //      System.out.println("крен = "+  ax  + "  тангаж =  " +  az);
                break;
            case Sensor.TYPE_LIGHT:
                light = event.values[0];
             //  System.out.println("свет = "+  light);
                break;
            case Sensor.TYPE_ORIENTATION:
                x = (int)event.values[0];
                y = (int)event.values[1]+90; // тангаж + вперед  - назад
                z = (int)event.values[2];  //крен +влево - вправо
         //       System.out.println("x = "+  x  + "  y =  " +  y+ "  z= "+ z);
                break;
            case Sensor.TYPE_LINEAR_ACCELERATION:
                hum = event.values[2];
                int k = (int)(hum*100);
                hum = - (double)k;//ускорение вперед положительное , назад отрицательное в см/с2
        //        System.out.println(hum);
                break;
            case  Sensor.TYPE_PRESSURE:
                press = event.values[0]*760/10.1325;
                int i = (int) press;
                press = (double)i/100;
        //        System.out.println(press);
                break;
            case  Sensor.TYPE_AMBIENT_TEMPERATURE:
                tempr = event.values[0];
                System.out.println(tempr);
                break;
        }
       Sensors = " tangaz_1 "+ az+ " kren_1 " + ax + " tangaz_2 "+  y + " kren_2 " + z + " forvard_accel "+ hum +
               " light " + light+ "  ";
       // System.out.println(Sensors);
    }
    @Override
    protected void onResume() {
        super.onResume();
        releaseCamera();
        releaseMediaRecorder();
        int t = getNumberOfCameras();
        mTextView.setText(""+t);
        if(camera == null) {
            camera = Camera.open();
           //  camera.unlock();
        }
        else{
        }
    }
    @Override
    protected void onPause() {
        super.onPause();
    }
    @Override
    protected void onStop() {
        super.onStop();
        releaseCamera();
        releaseMediaRecorder();
    }
    private boolean prepareVideoRecorder() {
        if (mediaRecorder==null)
        {mediaRecorder = new MediaRecorder();}
        camera.unlock();
        mediaRecorder.setCamera(camera);
        mediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA);
        switch (count) {
         case 1:
             mediaRecorder.setOutputFile(videoFile1.getAbsolutePath());
             break;
         case 2:
         mediaRecorder.setOutputFile(videoFile2.getAbsolutePath());
         break;
         case 3:
         mediaRecorder.setOutputFile(videoFile3.getAbsolutePath());
         break;
     }
        mediaRecorder.setPreviewDisplay(surfaceView.getHolder().getSurface());
        mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP);
        //mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
        mediaRecorder.setVideoSize(640,480);
        mediaRecorder.setOrientationHint(90);// меняет ориентацию на портретную, но на целевом устройстве
        mediaRecorder.setVideoFrameRate(30);
        mediaRecorder.setVideoEncoder(1);
        try {
            mediaRecorder.prepare();
        } catch (Exception e) {
            e.printStackTrace();
            releaseMediaRecorder();
            return false;
        }
        return true;
    }
    private void releaseMediaRecorder() {
        if (mediaRecorder != null) {
            mediaRecorder.release();
            mediaRecorder = null;
            if (camera != null) {
           // camera.lock();// если не сделать камера останавливается на последнем кадре
            }
        }
    }
    public void releaseCamera() {
        if (camera != null) {
         //   camera.setPreviewCallback(null);
          //     SurfaceView.getHolder().removeCallback(SurfaceView);
            camera.release();
            camera = null;
        }
    }
    private     class WriteVideo extends Thread{
        public void run ()
        {
                stopCamera=true;
            do{
              //     releaseMediaRecorder();
             //   releaseCamera();
                if(camera == null) {
                    camera = Camera.open(Camera.CameraInfo.CAMERA_FACING_BACK);
                }
                System.out.println("старт");
                if (prepareVideoRecorder()) {
                    mediaRecorder.start();
                } else {
                    releaseMediaRecorder();
                }
                try {
                    Thread.sleep(2000);//длина видео файла в секундах
                }
                catch (Exception e)
                {
                }
                count++;
                if(count==3){count=1;}//БЫЛО 4
                if (mediaRecorder != null) {
                   releaseMediaRecorder();
                }
                System.out.println("остановка");
                File f=null;
                switch (count) {
                    case 1:
                        f = videoFile2;// 3
                        break;
                    case 2:
                        f = videoFile1;//1
                        break;
                    case 3:
                        f = videoFile2;
                        break;
                }
                try {
                    BufferedInputStream bis = new BufferedInputStream(new FileInputStream(f));
                        data = new byte[bis.available()];
                        bis.read(data);
                        bis.close();
                }catch (Exception e) {
                    System.out.println(e);
                }
                if(Http_server.File_is_sent)// если файл отправлен можно отправлять следующий
                {
                    System.out.println("ожидаем готовность клиента");
                    new HTTP_Server_Calling().execute();
                    Http_server.File_is_sent=false;
                }
            }  while(stopCamera);
            if (mediaRecorder != null) {
                System.out.println("закончили");
        }
        }
    }
}
class ServerCreation extends AsyncTask<Void, Void, Void> {
    @Override
    protected void onPreExecute() {
        super.onPreExecute();
    }
    @Override
    protected Void doInBackground(Void... params) {
        try
        {
                CameraActivity.ss = new ServerSocket(40001);// порт для передачи видео файлов
                System.out.println(" Пробуем сервак");
                new Http_server(CameraActivity.ss.accept());
           CameraActivity.ss2 = new ServerSocket(40002);// порт для передачи показаний датчиков
        }catch (Exception e)
        {
            System.out.println(e);
            System.out.println(" НЕ Запустили сервак");
        }
        new HTTP_Server_Calling2().start();
        return null;
    }
    @Override
    protected void onPostExecute(Void result) {
    }
}
class HTTP_Server_Calling extends AsyncTask<Void, Void, Void> {
    @Override
    protected void onPreExecute() {
        super.onPreExecute();
    }
    @Override
    protected Void doInBackground(Void... params) {
        try {
            new Http_server(CameraActivity.ss.accept());
        } catch (Exception e) {
            System.out.println(e);
        }
        return null;
    }
    @Override
    protected void onPostExecute(Void result) {
    }
}
class HTTP_Server_Calling2 extends Thread// раз в секунду дергаем данные с сенсоров и запускаем слушатель
{
    public void run() {
        while (CameraActivity.stopCamera)
        {
            try {
                Thread.sleep(500);// скорость смены данных с сенсоров
                new Http_server_Sensors(CameraActivity.ss2.accept());
            } catch (Exception e) {
                System.out.println(e);
            }
        }
    }
}

class Http_server extends Thread {
    public  Socket socket;
    public static volatile boolean File_is_sent=true;
    Http_server(Socket s) {
        System.out.println(" Запустили сервак");
        socket = s;
        setPriority(MAX_PRIORITY);
        start();
  }
    public void run() {
                    try {
                        System.out.println(" Ждём запрос от клиента");
                        BufferedOutputStream bos = new BufferedOutputStream((socket.getOutputStream()));
                        bos.write(CameraActivity.data);
                        bos.flush();
                        bos.close();
                        socket.close();
                    } catch (Exception e) {
                        System.out.println(e);
                    }
                    File_is_sent = true;
        
    }
    
}

public class Http_server_Sensors extends Thread {
    public  Socket socket;
    PrintWriter pw;
    Http_server_Sensors(Socket s) {
        socket = s;
        setPriority(MAX_PRIORITY);
       start();
    }
    public void run() {
                try {
                pw = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true);// отправляем показания датчиков
                System.out.println(CameraActivity.Sensors);
                pw.println(CameraActivity.Sensors);//Показания датчиков
                pw.flush();
                pw.close();
                socket.close();
            } catch (Exception e) {
                System.out.println(e);
            }
        }
    }




Суть программы после запуска и включения камеры такова:
пишем видео две секунды в первый файл,
пишем видео две секунды во второй файл, а тем временем отправляем первый файл по TCP-IP через местный WI-FI на компьютер,
снова пишем первый файл, и отправляем тем временем второй,
и так далее.

Затем цикл повторяется до тех пор, пока не нажмётся кнопка «стоп» или не сдохнет батарея смартфона. В принципе, можно реализовать аналог нажатия кнопок, командами с компьютера, также по TCP, это не сложно.

Сначала видео буфер, на всякий случай, состоял из трех файлов формата 3GP, (пишем первый отправляем третий, пишем второй, отправляем первый, пишем третий, отправляем второй), но потом оказалось, что и двух файлов вполне хватает (запись и отправка друг друг не мешают).

При разрешении камеры 640 на 480, файлы получаются, где-то по 200-300 кБ, что для моего роутера вполне по зубам. Со звуком пока заморачиваться не стал, но там вроде всё несложно: устанавливаешь нужные аудио энкодеры, битрейты, количество каналов и тому подобное.

Немного позже, когда я отладил передачу видео, я дополнил код еще и передачей информации с датчиков смартфона. Передается всё тривиально одной строкой, но вот передать её через тот же сокет, что и видео мне не удалось. Видимо, классы для передачи строки PrintWriter и передачи двоичных данных BufferedOutputStream используют разные потоки, но зато один выходной буфер, где они успешно гадят друг другу. В итоге видео начинает глючить и сыпаться. К тому же видео файл передается раз в две секунды, а для датчиков этот интервал великоват. Поэтому, было решено разнести их в разные сокеты, чтобы они не мешали друг другу. По этой причине появился новый класс Http_server_Sensors.

Итак, отправку мы организовали, теперь снова вернемся на темную принимающую сторону.

Как мы уже увидели из самого первого примера, проигрывание видео файлов в JAVA приложении при помощи VLC плеера никаких проблем теперь не представляет. Главное, эти файлы получить.

За это отвечает следующая демонстрационная программа.

Video player
import java.awt.BorderLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import uk.co.caprica.vlcj.component.EmbeddedMediaPlayerComponent;
import uk.co.caprica.vlcj.discovery.NativeDiscovery;
import uk.co.caprica.vlcj.player.MediaPlayer;
import uk.co.caprica.vlcj.player.MediaPlayerEventAdapter;
import java.io.*;
import java.net.Socket;

public class VideoPlayer {
    public final JFrame frame;
    public static EmbeddedMediaPlayerComponent mediaPlayerComponent;
    public final JButton pauseButton;
    public final JButton rewindButton;
    public final JButton skipButton;
    public static String mr1, mr2;
    public static  boolean playing_finished = false;
    public  static  boolean File_1_play_starting = false;
    public  static  boolean File_1_play_finished = false;
    public  static  boolean File_2_play_starting = false;
    public  static  boolean File_2_play_finished = false;
//192.168.1.128
    public static void main(final String[] args) {
        new NativeDiscovery().discover();
        mr1 = "D:\\test1.3gp";
        mr2 = "D:\\test2.3gp";
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                VideoPlayer vp = new VideoPlayer();
                vp.mediaPlayerComponent.getMediaPlayer().setPlaySubItems(true);
                VideoPlayer.playing_finished=false;
                new Control().start();//запускаем контроллер событий
                //     while (!Http_client.File_ready)
                {
                    //  System.out.println("ждемс");
                    try {
                        Thread.sleep(100);
                    } catch (Exception e) {
                    }
                }
                //   while (true)
                {
                    playing_finished = false;
                }
                //   System.out.println("играем файл 1");
            }
        });
    }
    public VideoPlayer() {
        frame = new JFrame("My First Media Player");
        frame.setBounds(100, 100, 600, 400);
        frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        frame.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                System.out.println(e);
                mediaPlayerComponent.release();
                System.exit(0);
            }
        });
        JPanel contentPane = new JPanel();
        contentPane.setLayout(new BorderLayout());
        mediaPlayerComponent = new EmbeddedMediaPlayerComponent();
        contentPane.add(mediaPlayerComponent, BorderLayout.CENTER);
        frame.setContentPane(contentPane);
        JPanel controlsPane = new JPanel();
        pauseButton = new JButton("Pause");
        controlsPane.add(pauseButton);
        rewindButton = new JButton("Rewind");
        controlsPane.add(rewindButton);
        skipButton = new JButton("Skip");
        controlsPane.add(skipButton);
        contentPane.add(controlsPane, BorderLayout.SOUTH);
        pauseButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                mediaPlayerComponent.getMediaPlayer().pause();
            }
        });
        rewindButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                mediaPlayerComponent.getMediaPlayer().skip(-10000);
            }
        });
        skipButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                mediaPlayerComponent.getMediaPlayer().skip(10000);
            }
        });
        mediaPlayerComponent.getMediaPlayer().addMediaPlayerEventListener(new MediaPlayerEventAdapter() {
            @Override
            public void playing(MediaPlayer mediaPlayer) {
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        frame.setTitle(String.format(
                                "My First Media Player - %s",
                              mediaPlayerComponent.getMediaPlayer()
                        ));
                    }
                });
            }
            @Override
            public void finished(MediaPlayer mediaPlayer) {
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        playing_finished = true;
                        System.out.println("finished " + playing_finished);
                        //closeWindow();
                    }
                });
            }
            @Override
            public void error(MediaPlayer mediaPlayer) {
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        JOptionPane.showMessageDialog(
                                frame,
                                "Failed to play media",
                                "Error",
                                JOptionPane.ERROR_MESSAGE
                        );
                        closeWindow();
                    }
                });
            }
        });
        frame.setVisible(true);
        //mediaPlayerComponent.getMediaPlayer();
    }
    public void start(String mrl) {
        mediaPlayerComponent.getMediaPlayer().setPlaySubItems(true);
        mediaPlayerComponent.getMediaPlayer().prepareMedia(mrl);
        //mediaPlayerComponent.getMediaPlayer().parseMedia();
        mediaPlayerComponent.getMediaPlayer().playMedia(mrl);
          //  mediaPlayerComponent.
    }
    public void closeWindow() {
        frame.dispatchEvent(new WindowEvent(frame, WindowEvent.WINDOW_CLOSING));
    }
}
class PlayFile  {
        public  static void run(int number)
        {
            if (number==1)
            {
                VideoPlayer.mediaPlayerComponent.getMediaPlayer().prepareMedia(VideoPlayer.mr1);
                System.out.println("играем файл 1");
                VideoPlayer.mediaPlayerComponent.getMediaPlayer().start();
                VideoPlayer.mediaPlayerComponent.getMediaPlayer().playMedia(VideoPlayer.mr1);
                VideoPlayer.File_1_play_starting = true;
                VideoPlayer.File_1_play_finished = false;
                while (!VideoPlayer.playing_finished)
                {// пока проигрывание не закончится висим
                    try {
                        Thread.sleep(1);
                    } catch (Exception e)
                    {
                    }
                }
                VideoPlayer.mediaPlayerComponent.getMediaPlayer().stop();
                VideoPlayer.playing_finished = false;
                VideoPlayer.File_1_play_starting = false;
                VideoPlayer.File_1_play_finished = true;
            }
            {
                try {
                    Thread.sleep(10);
                } catch (Exception e)
                {
                }
            }
            if (number==2) {
                VideoPlayer.mediaPlayerComponent.getMediaPlayer().prepareMedia(VideoPlayer.mr2);
                System.out.println("играем файл 2");
                VideoPlayer.mediaPlayerComponent.getMediaPlayer().start();
                VideoPlayer.mediaPlayerComponent.getMediaPlayer().playMedia(VideoPlayer.mr2);
                VideoPlayer.File_2_play_starting = true;
                VideoPlayer.File_2_play_finished = false;
                while (!VideoPlayer.playing_finished)
                {
                    try {
                        Thread.sleep(1);
                    } catch (Exception e)
                    {
                    }
                }
                VideoPlayer.mediaPlayerComponent.getMediaPlayer().stop();
                VideoPlayer.playing_finished = false;
                VideoPlayer.File_2_play_starting = false;
                VideoPlayer.File_2_play_finished = true;
            }
            {
                try {
                    Thread.sleep(10);
                } catch (Exception e)
                {
                }
            }
        }
    }



public class Control extends Thread{
    public static  boolean P_for_play_1=false;
    public static  boolean P_for_play_2=false;
        public void run() {
     //
                new Http_client(1).start();
            while (!Http_client.File1_reception_complete) {
                try {
                    Thread.sleep(1);
                } catch (Exception e) {
                }
            }
                while (true)
                {
                    new Http_client(2).start();
                    PlayFile.run(1);
            while (!VideoPlayer.File_1_play_finished) {
                try {
                    Thread.sleep(1);
                } catch (Exception e) {
                }
            }
            while (!Http_client.File2_reception_complete) {
                try {
                    Thread.sleep(1);
                } catch (Exception e) {
                }
            }
            new Http_client(1).start();
            PlayFile.run(2);
                while (!VideoPlayer.File_2_play_finished) {
                    try {
                        Thread.sleep(1);
                    } catch (Exception e) {
                    }
                }
                while (!Http_client.File1_reception_complete) {
                    try {
                        Thread.sleep(1);
                    } catch (Exception e) {
                    }
                }
                //PlayFile.run(1);
        }
        }
}

public class Http_client extends Thread  {
    public static  boolean File1_starts_writing=false;
    public static  boolean File1_reception_complete=false;
    public static  boolean File2_starts_writing=false;
    public static  boolean File2_reception_complete=false;
    public int Number;
    public BufferedOutputStream bos;
    Http_client(int Number)
    {
        this.Number = Number;
    }
    public  void run(){
        try  {
            Socket socket= new Socket("192.168.1.128", 40001);
       //     System.out.println("соединение установлено");
       //     PrintWriter pw = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true);
        //    pw.println("ready");// Greetings with CLIENT
        //   System.out.println("запрос отправлен");
            BufferedInputStream bis = new BufferedInputStream(socket.getInputStream());
                if (Number ==1)
                {
                    File1_starts_writing=true;
                    File1_reception_complete=false;
                    System.out.println("качаем файл 1, можно играть файл 2");
                    bos = new BufferedOutputStream(new FileOutputStream(VideoPlayer.mr1));
                    byte[] buffer = new byte[32768];
                    while (true) {
                        // читаем данные в буфер
                        int readBytesCount = bis.read(buffer);
                        if (readBytesCount == -1) {
                            // данные закончились
                            break;
                        }
                        if (readBytesCount > 0) {
                            // данные были считаны - есть, что записать
                            bos.write(buffer, 0, readBytesCount);
                        }
                    }
                    System.out.println("файл считан");
                    System.out.println("файл получен");
                    bos.flush();
                    bos.close();
                    File1_starts_writing=false;
                    File1_reception_complete=true;
                }
            if (Number==2)
            {
                File2_starts_writing=true;
                File2_reception_complete=false;
                System.out.println("качаем файл 2, можно играть файл 1");
                bos = new BufferedOutputStream(new FileOutputStream(VideoPlayer.mr2));
                byte[] buffer = new byte[32768];
                while (true) {
                    // читаем данные в буфер
                    int readBytesCount = bis.read(buffer);
                    if (readBytesCount == -1) {
                        // данные закончились
                        break;
                    }
                    if (readBytesCount > 0) {
                        // данные были считаны - есть, что записать
                        bos.write(buffer, 0, readBytesCount);
                    }
                }
                System.out.println("файл считан");
                System.out.println("файл получен");
                bos.flush();
                bos.close();
                File2_starts_writing=false;
                File2_reception_complete=true;
            }
            socket.close();
       //     pw.close();
            bis.close();
            }
            catch(Exception e){
                System.out.println(e);
                System.out.println("gfgfg");
            }
           try {
               Thread.sleep(10);
           } catch (Exception e)
           {
           }
    }
}


Суть её несложна. Запускается TCP клиент, который начинает ожидать готовности сервера на смартфоне. Получив первый файл, тут же начинается его проигрывание и параллельно ожидается файл номер два. Дальше ожидается либо окончание приема второго файла, либо окончание проигрывания первого. Лучше, всего конечно три звездочки, когда файл скачивается быстрее, чем проигрывается. Если же первый файл проиграли, а второй ещё не получили, то всё — ждемс… Демонстрируем черный экран. Если же нет, быстренько запускаем проигрывание второй файл и параллельно снова качаем первый.
Была у меня смутная надежда, что пауза между переключения проигрываемых файлов будет меньше времени реакции человеческого глаза, но она не оправдалась. Неторопливый, что и говорить, этот VLC.



В итоге, получаем этакое мерзкое прерывающееся видео (таким, видимо, узрел мир первый трилобит), где ещё постоянно происходит подстройка резкости. И надо учесть, что видео еще и запаздывает на две секунды. Короче, в продакшн такое я выкладывать не рекомендую. А вот у меня на безрыбье и трилобита, как говорится…

Резюмируя выше сделанное можно совершенно точно сказать, что:

Передача видео кусочками — по сути неработоспособна и может пригодится разве что в тех случаях, когда продолжительность видео достаточно велика и вам не требуется секундных реакций на происходящее.

Передача видео по TCP-IP тоже идея неверная, что бы там не говорили некоторые личности на Хабре о скорости передачи данных по этому протоколу (что мол даже быстрее UDP). Конечно, современные беспроводные интрасети имеют неплохие характеристики, чтобы обеспечить непрерывные рукопожатия TCP серверов и клиентов, да и сам TCP вроде как модернизировали для длинных данных, но все равно затыки между проигрыванием видеокусочков, как можно заметить на демо, периодически появляются.

Но, по крайней мере, на будущее появились следующие мысли:

  1. отправлять покадрово (не видео, а фото) через UDP, а управляющую информацию по TCP,
  2. гнать фото кадры все подряд через UDP но с синхросигналами в том же канале.

Конечно, здесь пока вопросов больше, чем ответов. Хватит ли скорости на приеме у JAVA с её-то уровнем абстракции в работе с сетью и изображениями? Можно ли на доступном мне уровне в Android делать по 30 нормальных съемок в секунду? Надо ли будет их жать перед отправкой, чтобы снизить битрейт? А тогда хватит ли быстродействия JAVA на упаковку и распаковку? И если, вдруг, что-то получится, то удастся ли пройти следующий шаг, прикрутить сюда систему компьютерного зрения JAVA OpenCV? Самому, конечно, потоковое видео с уровня пола всегда интересно посмотреть, но мы же не должны забывать о высшей цели — роботележке с интеллектом муравья!

Но, пока имея, то что имеем, вернёмся к текущей телеге. Старая программа из позапрошлой статьи для AVR микроконтроллера на Ардуино платформе почти не изменилась, добавился только выбор по ветвлению — автономная езда или под управлением оператора. Данные, которые тележка (спинной мозг) передает по WI-FI те же — пройденный путь. Чуть позже я приделал еще и передачу температуры критичного элемента — драйвера моторов. Всё это передается и принимается сначала по UART, а уже при выходе в сеть по UDP. Кода не привожу, весь полный разбор в позапрошлой статье.



На два моторчика его (драйвера) ещё хватает более-менее, но с четырьмя через некоторое время он перегревается до неработоспособного состояния. Вначале я пытался использовать простейшие аналоговые температурные датчики на основе стабилитронов, типа LM335, но из этого ничего не вышло. Источника опорного напряжения, сокращенно ИОНа у меня на плате-то не было. А ловить милливольты на садящейся батарее смысла нет. Кстати, о батарее — когда мне надоело постоянно вынимать и вставлять для перезарядки литиевые-аккумуляторы формата 14500, я просто взял запасной аккумулятор от шуруповерта и телега начала ездить непрерывно полтора часа, плюс еще приобрела угрожающий вид и массу (да именно на этой батарее сидят её «глазки»). Посему для измерения температуры я приладил неисправный гироскоп-акселерометр на базе L3G4200D. Температуру он, к счастью, ещё мерил и передавал по шине I2C.

Смартфон же, сидящий позади батареи и поверх глазков-эхолокаторов — передаёт видео поток, показания акселерометров (по ним можно прикидывать крен и тангаж телеги, когда она стоит), крен и тангаж в градусах в динамике, уже посчитанные самим смартфоном (весьма удобная опция), линейное ускорение по ходу движения, освещенность. А вообще, понятное дело, можно передавать всё, что может померить ваш смартфон от давления воздуха, до направления на север.

В итоге, приложение на JAVA приобрело такой вид:



Самое смешное, что оно реально похоже на панель управления советским луноходом, только у меня телеэкран сбоку, а у них в центре.



При включении, сначала соединяемся с телегой и смартфоном на ней, выбираем режим ручного управления или полной автономии — и вперёд! До тех пор, пока окошко температуры драйвера не пожелтеет, а затем не станет красным. Графика!

Примерно так это выглядит вживую.


Программу здесь не привожу, поскольку как обычно, построение окошек занимает 95% кода. Её можно найти здесь.

В будущем же, головной мозг (смартфон), должен научиться передавать нормальное (а не такое как сейчас) видео на компьютер, где оно по хорошему должно как-то распознаться (пока цель — построение карты помещения на уровне пола и определение своего местоположения). Ну, а уж совсем уже при коммунизме, хотелось бы это портировать обратно на смартфон, чтобы компьютер был ему не нужен.

Если хоть-что то получится, обязательно выложу. Спасибо за внимание.
Источник: https://habr.com/ru/post/448516/


Интересные статьи

Интересные статьи

Некоторые производители гаджетов пытаются уйти от концепта сенсорного смартфона без физической клавиатуры, которые очень похожи друг на друга. До недавнего времени наиболее известным п...
Привет, сегодня я начну рассказывать историю разработки аппаратной платформы для создания «умных» рабочих мест Smart Workspace от зарождения идеи до запуска в серийное производство н...
Схема утечки данных через Web Proxy Auto-Discovery (WPAD) при коллизии имён (в данном случае коллизия внутреннего домена с названием одной из новых gTLD, но суть та же). Источник: исследование ...
Кажется, все согласились: текст влияет на UX, поэтому нужно писать понятно, а непонятно писать не нужно. Отвечать за UX-тексты должен специальный человек — UX-редактор, контент-стратег. Но таки...
Добрый день. Меня зовут Никита Сергеевич, мне 14 лет и в этом посте я имею желание рассказать вам о разработанною мною пол года назад устройстве (и приложении), потому что готовое устройство спая...