Евгений Колесников před 3 roky
rodič
revize
e563a5ea35
7 změnil soubory, kde provedl 162 přidání a 19 odebrání
  1. 146 8
      articles/android_tv.md
  2. 12 11
      cinema/index.js
  3. 4 0
      cinema/swagger/cinema.yml
  4. binární
      img/tv_07.png
  5. binární
      img/tv_08.png
  6. binární
      img/tv_09.png
  7. binární
      img/tv_10.png

+ 146 - 8
articles/android_tv.md

@@ -14,7 +14,7 @@
 
 ## Создание проекта в Android Studio
 
-Запустив Android Studio, необходимо создать новый проект. При создании выбрать платформу TV и указать минимальную версию SDK. Android Studio предложит нам создать "Blank Activity", его и создадим (в оригинальной статье предлагают руками добавлять классы, файлы разметки и фрагменты, но это долго, проще выкинуть лишнее из рабочего проекта) 
+Запустив Android Studio, необходимо создать новый проект. При создании выбрать платформу TV и указать минимальную версию SDK. Android Studio предложит нам создать "Blank Activity", его и создадим (в оригинальной статье предлагают создать пустой проект без активности "No Activity" и руками добавлять классы, файлы разметки и фрагменты, но это долго, проще выкинуть лишнее из рабочего проекта) 
 
 ![](../img/tv_01.png)
 
@@ -97,7 +97,7 @@ class MainActivity : FragmentActivity()
     tools:ignore="MergeRootFrame" />
 ```    
 
-По коду мы видим, что активность наследуется от **FragmentActivity** и при запуске содержимое `main_browse_fragment` заменяется тем, что сгенерирует класс **MainFragment**. (Файла разметки `main_browse_fragment` в проекте нет, похоже он реализован в библиотеке **Leanback**)
+По коду мы видим, что вёрстка приложени состоит из единственного фрагмента, содержимое для которого генерится в классе **MainFragment**.
 
 ### MainFragment
 
@@ -147,7 +147,7 @@ override fun onActivityCreated(savedInstanceState: Bundle?) {
 
     Свойство *list* класса **MovieList** возвращает массив объектов **Movie**, реализацию можно не разбирать, мы в реальном проекте всё-равно этот список будем получать динамически из АПИ.
 
-1. `val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())` - Создаётся **стандартный** адаптер (массива объектов) использующий **стандартный** класс для вывода элементов в виде "строки".
+1. `val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())` - Создаётся **стандартный** адаптер **ArrayObjectAdapter** использующий **стандартный** класс-представление **ListRowPresenter** для вывода "строки". "Строка" (*ListRow* - строка списка), отображаемая этим представлением, выводит во фрагменте заголовков (*HeadersFragment* - левая часть экрана) заголовок, а во фрагменте строк (*RowsFragment* - правая часть) содержимое.
 
 1. `val cardPresenter = CardPresenter()` - создание самописанного (реализующего абстрактный класс) экземпляра представления карточки фильма
 
@@ -165,7 +165,7 @@ override fun onActivityCreated(savedInstanceState: Bundle?) {
 
     ![](../img/tv_05.png)
 
-    Далее создаётся адаптер для строки, которая будет отображаться в RowFragment (правая часть экрана) и заполняется фильмами из списка
+    Далее создаётся адаптер для содержимого, которое будет отображаться в *RowsFragment* (правая часть экрана) и заполняется фильмами из списка:
 
     ```kt
     val listRowAdapter = ArrayObjectAdapter(cardPresenter)
@@ -174,18 +174,25 @@ override fun onActivityCreated(savedInstanceState: Bundle?) {
     }
     ```
 
-    В конце цикла создаётся заголовок для категории и заголовок вместе со списком фильмов добавляется в адаптер строк *rowsAdapter*
+    В конце цикла создаётся заголовок для категории. Заголовок вместе со списком фильмов добавляется в адаптер "строк" *rowsAdapter*:
 
     ```kt
     val header = HeaderItem(
         i.toLong(), 
         MovieList.MOVIE_CATEGORY[i])
+
     rowsAdapter.add(
         ListRow(
             header, 
             listRowAdapter))
     ```
 
+    В итоге структура выглядит примерно так:
+
+    ![](../img/tv_07.png)
+
+    **ListRowPresenter** это стандартный класс библиотеки **Leanback**, отвечающий за размещение заголовка и содержимого по разным фрагментам (Headers и Rows). **CardPresenter** класс, отвечающий за отображение отдельного элемента (в правом фрагменте).
+
 1. Добавление строки с настройками
 
     ![](../img/tv_06.png)
@@ -242,7 +249,6 @@ setOnSearchClickedListener {
 
 Назначение этого события **включает** иконку поиска. Реализации поиска пока никакой нет.
 
-
 ```kt
 onItemViewClickedListener = ItemViewClickedListener()
 ```
@@ -259,7 +265,7 @@ onItemViewSelectedListener = ItemViewSelectedListener()
 
 ### Загрузочный экран
 
-Не знаю, будет ли это на демо-экзамене, но для демонстрации работы с обычными активностями андроид добавим загрузочный экран:
+Не знаю, будет ли это на демо-экзамене, но для демонстрации работы с обычными *activity* добавим загрузочный экран:
 
 1. Добавьте пустую активность (в контекстном меню пакета `New -> Activity -> Empty Activity`)
 
@@ -293,10 +299,142 @@ onItemViewSelectedListener = ItemViewSelectedListener()
 
 Всё замечательно работает!!!
 
-### Получение списка фильмов и категорий
+### Получение списка фильмов с сервера
 
 Что-бы не терять время просто так, поместим этот код в загрузочную активность (заодно проверим как тут работает класс **Application**)
 
+В классе приложения (**MyApp**) описываем переменную
+`val movieList = ArrayList<Movie>()`, в которую будем записывать информацию о фильмах (Класс **Movie** я пока оставил как есть).
+
+
+```kt
+override fun onCreate(savedInstanceState: Bundle?) 
+{
+    super.onCreate(savedInstanceState)
+    setContentView(R.layout.activity_launch)
+    app = applicationContext as MyApp
+    that = this
+    Timer().schedule(3000L){
+        that.runOnUiThread {
+            startActivity(Intent(that, MainActivity::class.java))
+        }
+    }
+    // запрос списка фильмов вынесен в отдельный метод
+    getMovieList()
+}
+
+private fun getMovieList() {
+    Http.call("http://cinema.kolei.ru/movies?filter=new"){ 
+        response, error ->
+        try {
+            if (error != null) throw error
+            if (!response!!.isSuccessful) 
+                throw Exception(response.message)
+
+            var json = JSONArray(response.body!!.string())
+
+            // работу с классом приложения на всякий случай тоже заворачиваю в UI поток
+            runOnUiThread {
+                app.movieList.clear()
+                for (i in 0 until json.length()){
+                    val item = json.getJSONObject(i)
+                    app.movieList.add(Movie(
+                        id = item.getLong("movieId"),
+                        title = item.getString("name"),
+                        description = item.getString("description"),
+                        cardImageUrl = "http://cinema.kolei.ru/up/images/${item.getString("poster")}"
+                    ))
+                }
+            }
+        } catch (e: Exception) {
+            // любую ошибку показываем на экране
+            runOnUiThread {
+                AlertDialog.Builder(this)
+                    .setTitle("Ошибка")
+                    .setMessage(e.message)
+                    .setPositiveButton("OK", null)
+                    .create()
+                    .show()
+            }
+        }
+    }
+}
+```
+
+В классе **MainFragment** указатель на *app* получаем через ссылку на текущую активность (я упоминал в прошлой лекции, что фрагмент не может существовать сам по себе, а работает в контексте какой-то активности)
+
+```kt
+override fun onActivityCreated(savedInstanceState: Bundle?) 
+{
+    super.onActivityCreated(savedInstanceState)
+    app = activity?.application as MyApp
+```
+
+И в методе *loadRows* задаём в качестве списка фильмов тот, который получили с сервера
+
+```kt
+private fun loadRows() {
+    val list = app.movieList  //MovieList.list
+```
+
+Запускаем проект и видим, что данные получены и отображаются верно (нужно только поправить вёрстку) и знакомые нам механизмы работы с классом приложения и сетевые запросы работают как надо:
+
+![](../img/tv_08.png)
+
+### Группировка по категориям
+
+В АПИ в информацию о фильме я добавил категорию. 
+
+Добавим поле для категории в класс **Movie**
+
+```kt
+var category: String? = null
+```
+
+Не забываем заполнить его при получении данных:
+
+```kt
+app.movieList.add(Movie(
+    id = item.getLong("movieId"),
+    title = item.getString("name"),
+    description = item.getString("description"),
+    cardImageUrl = "http://cinema.kolei.ru/up/images/${item.getString("poster")}",
+    category = item.getString("category")
+))
+```
+
+В методе *loadRows*, перед формированием данных для отображения, получаем список уникальных названий категорий:
+
+```kt
+val categoriesList = list.map{m->m.category}.distinct()
+```
+
+С методом *distinct* вы уже знакомы, а метод *map* преобразует содержимое массива. В нашем случае мы выбираем только название категорий (в итоге получается массив строк).
+
+![](../img/tv_09.png)
+
+Теперь перебираем список категорий, создавая для каждой свой *listRowAdapter*. Далее во вложенном цикле заполняем этот адаптер подходящими по категории фильмами. После заполнения одной категории заголовок (категория) и содержимое этой категории (*listRowAdapter*) записываются в общий 
+
+```kt
+for (i in categoriesList.indices){
+    val listRowAdapter = ArrayObjectAdapter(cardPresenter)
+
+    // формируем элементы строки, фильтруя фильмы по категории
+    for (j in list.indices) {
+        if (list[j].category != null && list[j].category == categoriesList[i]) {
+            listRowAdapter.add(list[j])
+        }
+    }
+
+    // сформированную строку с заголовком пишем в rowsAdapter
+    val header = HeaderItem(categoriesList[i])
+    rowsAdapter.add(ListRow(header, listRowAdapter))
+}
+```
+
+![](../img/tv_10.png)
+
+### Настройка вёрстки карточки фильма
 
 <!-- https://tv.withgoogle.com/# -->
 

+ 12 - 11
cinema/index.js

@@ -33,16 +33,16 @@ app.use((req, res, next)=>{
 
 const registeredUsers = []
 const movies = [
-  {movieId: 1, name: 'Дюна', description: 'Атрейдесы прибывают на планету, где им никто не рад. Фантастический эпос Дени Вильнёва с шестью «Оскарами»', age: "12", images: [], poster: 'duna.webp', tags: [], filters: ['new','inTrend','forMe']},
-  {movieId: 2, name: 'Легенда о Зелёном Рыцаре', description: 'Наследник короля принимает вызов таинственного рыцаря. Захватывающее фэнтези по мотивам средневековой поэмы', age: "18", images: [], poster: 'green.webp', tags: [], filters: ['new','inTrend','forMe']},
-  {movieId: 3, name: 'Главный герой', age: "16", images: [], poster: 'maincharacter.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Парень по имени Парень счастлив. Он живет в лучшем в мире городе Городе, работает на лучшей в мире работе в Банке и дружит с охранником по имени Приятель. И его совершенно не волнует, что Банк грабят по нескольку раз на дню, а улицы Города напоминают зону военных действий. Единственное, чего Парню не хватает для полного счастья — идеальной девушки, к которой у него имеется точный список требований. И вот однажды он видит на улице красотку, точь-в-точь как в его мечтах. Эта встреча изменит не только нашего главного героя, но и перевернёт весь известный ему мир.'},
-  {movieId: 4, name: 'Петр I: Последний царь и первый император', age: "12", images: [], poster: 'Petr1.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Фигура императора Петра Великого, как и эпоха его становления и правления, до сих пор будоражит умы людей во всем мире. Создатели отвечают на вопросы, как занять престол, когда ты — четырнадцатый ребенок в семье; как отвоевать выход к морю, когда в стране нет профессиональной армии и флота; как за несколько десятилетий вывести в мировые лидеры страну, с которой раньше никто не считался и многие другие.'},
-  {movieId: 5, name: 'Либерея: Охотники за сокровищами', age: "12", images: [], poster: 'Liberia.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'При строительстве столичного метро рабочие обнаруживают драгоценный оклад, который доказывает — легендарная Библиотека Ивана Грозного существует! Но находка оказывается забыта на долгие годы, и уже в наше время попадает в руки ни о чем не подозревающего Ильи. Теперь его жизнь в опасности, ведь за старинным артефактом начинают охоту могущественные силы! Парень вынужден объединиться со странным незнакомцем, который утверждает, что оклад — это ключ к обнаружению Библиотеки. Помочь им в поисках и разгадать древние шифры берется красотка-филолог Арина. Теперь, чтобы обрести новые ключи-подсказки и приблизиться к разгадке, трио авантюристов нужно побывать в затерянных и опасных местах, разбросанных по всей России: от Вологды до Нарьян-Мара, на суше, под водой и даже в тайных подземельях Кремля.'},
-  {movieId: 6, name: 'Грозный папа', age: '6', images: [], poster: 'formidableDad.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Поссорившись с сыном, царь Иван Грозный случайно ранит его – как на знаменитой картине Репина. Жизнь царевича на волоске. Чтобы все исправить, Грозный хочет отправиться в прошлое с помощью волшебного гримуара. Однако что-то пошло не так, и Грозный попадает в наше время, где знакомится с семьей Осиповых. Никита Осипов – неудачливый археолог и такой же неудачливый отец. Он давно потерял контакт с детьми – Ромкой и Полей. Но теперь они вместе отправляются в путешествие, чтобы помочь Грозному отыскать гримуар и спасти царевича.'},
-  {movieId: 7, name: 'Сердце пармы', age: '16', images: [], poster: 'Heart.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Русский князь Михаил и юная Тиче — дети разных народов, разных миров и разных богов. Любовь молодого воителя и ведьмы-ламии кажется невозможной, но преодолевает все запреты, запуская маховик рока. Отныне только от Михаила зависит будущее родной пармы, древних суровых земель, напоенных чудодейственной мощью кровавых языческих богов. Здесь сталкиваются герои и призраки, князья и шаманы, вогулы и московиты. Здесь расстаться с жизнью — не так страшно, как выбрать между долгом, верностью братству и любовью к единственной женщине на свете.'},
-  {movieId: 8, name: 'Большое путешествие. Специальная доставка', age: '6', images: [], poster: 'bigAdventure.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Прошло время с тех пор, как заяц Оскар и медведь Мик-Мик в компании своих друзей вернули домой маленького панду. С тех пор жили они спокойно и размеренно. Мик-Мик заботился о своих пчелах, а Оскар организовал в лесу американские горки. И вот однажды к берегу Мик-Мика прибивает корзину с малышом гризли. Кто-то снова перепутал адреса, а разбираться с этим придется Мик-Мику и Оскару. В компании друзей они отправляются в новое путешествие — теперь, чтобы вернуть домой малыша гризли.'},
-  {movieId: 9, name: 'Шрамы Парижа', age: '18', images: [], poster: 'scars.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'В ноябре 2015 года Париж пережил самые страшные теракты в своей истории. Жертвами тщательно спланированных актов насилия стали почти 400 человек. Но на этом преступники не собирались останавливаться. Чтобы предотвратить будущие угрозы, двум агентам придется провести одно из самых крупных расследований в истории Старого Света и помешать преступникам нанести новый удар. Теперь в опасности не только Франция, но и вся Европа.'},
-  {movieId: 10, name: 'Паранормальные явления. Дом призраков', age: '16', images: [], poster: 'Paranormal.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Когда-то Шон был популярным видеоблогером, сделавшим имя на экстремальных роликах, в которых он бросал вызов собственным страхам, но однажды вляпался в скандал и потерял всех спонсоров. Записав видео с извинениями и снова получив финансирование, парень возвращается с новым леденящим душу проектом. Шон собирается провести ночной стрим из дома с привидениями, где более 100 лет назад повесилась одинокая женщина, а после неоднократно фиксировалась паранормальная активность.'}
+  {movieId: 1, category:'Фентези', name: 'Дюна', description: 'Атрейдесы прибывают на планету, где им никто не рад. Фантастический эпос Дени Вильнёва с шестью «Оскарами»', age: "12", images: [], poster: 'duna.webp', tags: [], filters: ['new','inTrend','forMe']},
+  {movieId: 2, category:'Фентези', name: 'Легенда о Зелёном Рыцаре', description: 'Наследник короля принимает вызов таинственного рыцаря. Захватывающее фэнтези по мотивам средневековой поэмы', age: "18", images: [], poster: 'green.webp', tags: [], filters: ['new','inTrend','forMe']},
+  {movieId: 3, category:'Мелодрама', name: 'Главный герой', age: "16", images: [], poster: 'maincharacter.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Парень по имени Парень счастлив. Он живет в лучшем в мире городе Городе, работает на лучшей в мире работе в Банке и дружит с охранником по имени Приятель. И его совершенно не волнует, что Банк грабят по нескольку раз на дню, а улицы Города напоминают зону военных действий. Единственное, чего Парню не хватает для полного счастья — идеальной девушки, к которой у него имеется точный список требований. И вот однажды он видит на улице красотку, точь-в-точь как в его мечтах. Эта встреча изменит не только нашего главного героя, но и перевернёт весь известный ему мир.'},
+  {movieId: 4, category:'Исторический', name: 'Петр I: Последний царь и первый император', age: "12", images: [], poster: 'Petr1.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Фигура императора Петра Великого, как и эпоха его становления и правления, до сих пор будоражит умы людей во всем мире. Создатели отвечают на вопросы, как занять престол, когда ты — четырнадцатый ребенок в семье; как отвоевать выход к морю, когда в стране нет профессиональной армии и флота; как за несколько десятилетий вывести в мировые лидеры страну, с которой раньше никто не считался и многие другие.'},
+  {movieId: 5, category:'Приключения', name: 'Либерея: Охотники за сокровищами', age: "12", images: [], poster: 'Liberia.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'При строительстве столичного метро рабочие обнаруживают драгоценный оклад, который доказывает — легендарная Библиотека Ивана Грозного существует! Но находка оказывается забыта на долгие годы, и уже в наше время попадает в руки ни о чем не подозревающего Ильи. Теперь его жизнь в опасности, ведь за старинным артефактом начинают охоту могущественные силы! Парень вынужден объединиться со странным незнакомцем, который утверждает, что оклад — это ключ к обнаружению Библиотеки. Помочь им в поисках и разгадать древние шифры берется красотка-филолог Арина. Теперь, чтобы обрести новые ключи-подсказки и приблизиться к разгадке, трио авантюристов нужно побывать в затерянных и опасных местах, разбросанных по всей России: от Вологды до Нарьян-Мара, на суше, под водой и даже в тайных подземельях Кремля.'},
+  {movieId: 6, category:'Исторический', name: 'Грозный папа', age: '6', images: [], poster: 'formidableDad.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Поссорившись с сыном, царь Иван Грозный случайно ранит его – как на знаменитой картине Репина. Жизнь царевича на волоске. Чтобы все исправить, Грозный хочет отправиться в прошлое с помощью волшебного гримуара. Однако что-то пошло не так, и Грозный попадает в наше время, где знакомится с семьей Осиповых. Никита Осипов – неудачливый археолог и такой же неудачливый отец. Он давно потерял контакт с детьми – Ромкой и Полей. Но теперь они вместе отправляются в путешествие, чтобы помочь Грозному отыскать гримуар и спасти царевича.'},
+  {movieId: 7, category:'Мелодрама', name: 'Сердце пармы', age: '16', images: [], poster: 'Heart.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Русский князь Михаил и юная Тиче — дети разных народов, разных миров и разных богов. Любовь молодого воителя и ведьмы-ламии кажется невозможной, но преодолевает все запреты, запуская маховик рока. Отныне только от Михаила зависит будущее родной пармы, древних суровых земель, напоенных чудодейственной мощью кровавых языческих богов. Здесь сталкиваются герои и призраки, князья и шаманы, вогулы и московиты. Здесь расстаться с жизнью — не так страшно, как выбрать между долгом, верностью братству и любовью к единственной женщине на свете.'},
+  {movieId: 8, category:'Мультики', name: 'Большое путешествие. Специальная доставка', age: '6', images: [], poster: 'bigAdventure.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Прошло время с тех пор, как заяц Оскар и медведь Мик-Мик в компании своих друзей вернули домой маленького панду. С тех пор жили они спокойно и размеренно. Мик-Мик заботился о своих пчелах, а Оскар организовал в лесу американские горки. И вот однажды к берегу Мик-Мика прибивает корзину с малышом гризли. Кто-то снова перепутал адреса, а разбираться с этим придется Мик-Мику и Оскару. В компании друзей они отправляются в новое путешествие — теперь, чтобы вернуть домой малыша гризли.'},
+  {movieId: 9, category:'Мелодрама', name: 'Шрамы Парижа', age: '18', images: [], poster: 'scars.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'В ноябре 2015 года Париж пережил самые страшные теракты в своей истории. Жертвами тщательно спланированных актов насилия стали почти 400 человек. Но на этом преступники не собирались останавливаться. Чтобы предотвратить будущие угрозы, двум агентам придется провести одно из самых крупных расследований в истории Старого Света и помешать преступникам нанести новый удар. Теперь в опасности не только Франция, но и вся Европа.'},
+  {movieId: 10, category:'Ужасы', name: 'Паранормальные явления. Дом призраков', age: '16', images: [], poster: 'Paranormal.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Когда-то Шон был популярным видеоблогером, сделавшим имя на экстремальных роликах, в которых он бросал вызов собственным страхам, но однажды вляпался в скандал и потерял всех спонсоров. Записав видео с извинениями и снова получив финансирование, парень возвращается с новым леденящим душу проектом. Шон собирается провести ночной стрим из дома с привидениями, где более 100 лет назад повесилась одинокая женщина, а после неоднократно фиксировалась паранормальная активность.'}
 ]
 
 const chats = [
@@ -172,7 +172,8 @@ app.get('/movies', cors(), (req,res)=>{
         age: m.age, 
         images: m.images, 
         poster: m.poster, 
-        tags: m.tags
+        tags: m.tags,
+        category: m.category
       }
     })
     res.json(mapped)

+ 4 - 0
cinema/swagger/cinema.yml

@@ -380,8 +380,12 @@ components:
             description: Название картинки постера
           tags:
             $ref: '#/components/schemas/Tag'
+          category:
+            type: string
+            description: Жанр фильма
         required:
           - movieId
+          - category
     Tag:
       type: array
       items:

binární
img/tv_07.png


binární
img/tv_08.png


binární
img/tv_09.png


binární
img/tv_10.png