load thumbs from api

This commit is contained in:
Felix 2020-04-18 15:53:39 +02:00
parent 6c155e2706
commit 6d5b9dc791
15 changed files with 1136 additions and 220 deletions

View file

@ -149,8 +149,8 @@ dependencies {
kapt "androidx.room:room-compiler:$roomVersion"
api "androidx.room:room-ktx:$roomVersion"
implementation 'com.squareup.retrofit2:retrofit:2.6.4'
implementation 'com.squareup.retrofit2:converter-gson:2.6.4'
api 'com.squareup.retrofit2:retrofit:2.6.4'
api 'com.squareup.retrofit2:converter-gson:2.6.4'
api "com.google.code.gson:gson:2.8.6"
api 'com.google.android.exoplayer:exoplayer:2.9.6'

View file

@ -0,0 +1,777 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "cd02652f6c74b9e0a5371cb34f851365",
"entities": [
{
"tableName": "conference",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conferenceGroupId` INTEGER NOT NULL, `acronym` TEXT NOT NULL, `aspectRatio` TEXT NOT NULL, `title` TEXT NOT NULL, `slug` TEXT NOT NULL, `webgenLocation` TEXT NOT NULL, `scheduleUrl` TEXT, `logoUrl` TEXT NOT NULL, `imagesUrl` TEXT NOT NULL, `recordingsUrl` TEXT NOT NULL, `url` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `tagsUsefull` INTEGER NOT NULL, `lastReleasedAt` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conferenceGroupId",
"columnName": "conferenceGroupId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "acronym",
"columnName": "acronym",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "aspectRatio",
"columnName": "aspectRatio",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "slug",
"columnName": "slug",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webgenLocation",
"columnName": "webgenLocation",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scheduleUrl",
"columnName": "scheduleUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "logoUrl",
"columnName": "logoUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imagesUrl",
"columnName": "imagesUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recordingsUrl",
"columnName": "recordingsUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tagsUsefull",
"columnName": "tagsUsefull",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastReleasedAt",
"columnName": "lastReleasedAt",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_conference_acronym",
"unique": true,
"columnNames": [
"acronym"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conference_acronym` ON `${TABLE_NAME}` (`acronym`)"
}
],
"foreignKeys": []
},
{
"tableName": "event",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conferenceId` INTEGER NOT NULL, `conference` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `slug` TEXT NOT NULL, `link` TEXT, `description` TEXT, `originalLanguage` TEXT NOT NULL, `date` TEXT, `releaseDate` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `length` INTEGER NOT NULL, `thumbUrl` TEXT NOT NULL, `posterUrl` TEXT NOT NULL, `frontendLink` TEXT, `url` TEXT NOT NULL, `conferenceUrl` TEXT NOT NULL, `isPromoted` INTEGER NOT NULL, `viewCount` INTEGER NOT NULL, `persons` TEXT, `tags` TEXT, `timelineUrl` TEXT NOT NULL, `thumbnailsUrl` TEXT NOT NULL, FOREIGN KEY(`conferenceId`) REFERENCES `conference`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conferenceId",
"columnName": "conferenceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conference",
"columnName": "conference",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subtitle",
"columnName": "subtitle",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "slug",
"columnName": "slug",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "originalLanguage",
"columnName": "originalLanguage",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "releaseDate",
"columnName": "releaseDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "length",
"columnName": "length",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbUrl",
"columnName": "thumbUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "posterUrl",
"columnName": "posterUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "frontendLink",
"columnName": "frontendLink",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conferenceUrl",
"columnName": "conferenceUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPromoted",
"columnName": "isPromoted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "viewCount",
"columnName": "viewCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "persons",
"columnName": "persons",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUrl",
"columnName": "timelineUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailsUrl",
"columnName": "thumbnailsUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_event_guid",
"unique": true,
"columnNames": [
"guid"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_event_guid` ON `${TABLE_NAME}` (`guid`)"
},
{
"name": "index_event_frontendLink",
"unique": false,
"columnNames": [
"frontendLink"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_event_frontendLink` ON `${TABLE_NAME}` (`frontendLink`)"
},
{
"name": "index_event_conferenceId",
"unique": false,
"columnNames": [
"conferenceId"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_event_conferenceId` ON `${TABLE_NAME}` (`conferenceId`)"
}
],
"foreignKeys": [
{
"table": "conference",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"conferenceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "recording",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` INTEGER NOT NULL, `size` INTEGER NOT NULL, `length` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `language` TEXT NOT NULL, `filename` TEXT NOT NULL, `state` TEXT NOT NULL, `folder` TEXT NOT NULL, `isHighQuality` INTEGER NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `updatedAt` TEXT NOT NULL, `recordingUrl` TEXT NOT NULL, `url` TEXT NOT NULL, `eventUrl` TEXT NOT NULL, `conferenceUrl` TEXT NOT NULL, `backendId` INTEGER NOT NULL, FOREIGN KEY(`eventId`) REFERENCES `event`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventId",
"columnName": "eventId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "length",
"columnName": "length",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHighQuality",
"columnName": "isHighQuality",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "width",
"columnName": "width",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recordingUrl",
"columnName": "recordingUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "eventUrl",
"columnName": "eventUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conferenceUrl",
"columnName": "conferenceUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "backendId",
"columnName": "backendId",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_recording_eventId",
"unique": false,
"columnNames": [
"eventId"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_recording_eventId` ON `${TABLE_NAME}` (`eventId`)"
},
{
"name": "index_recording_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recording_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_recording_backendId",
"unique": true,
"columnNames": [
"backendId"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recording_backendId` ON `${TABLE_NAME}` (`backendId`)"
}
],
"foreignKeys": [
{
"table": "event",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"eventId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "related",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `parentEventId` INTEGER NOT NULL, `relatedEventGuid` TEXT NOT NULL, `weight` INTEGER NOT NULL, FOREIGN KEY(`parentEventId`) REFERENCES `event`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentEventId",
"columnName": "parentEventId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "relatedEventGuid",
"columnName": "relatedEventGuid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_related_parentEventId_relatedEventGuid",
"unique": true,
"columnNames": [
"parentEventId",
"relatedEventGuid"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_related_parentEventId_relatedEventGuid` ON `${TABLE_NAME}` (`parentEventId`, `relatedEventGuid`)"
}
],
"foreignKeys": [
{
"table": "event",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"parentEventId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "conference_group",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `order_index` INTEGER NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "order_index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_conference_group_name",
"unique": true,
"columnNames": [
"name"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conference_group_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "playback_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `event_guid` TEXT NOT NULL, `progress` INTEGER NOT NULL, `watch_date` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventGuid",
"columnName": "event_guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "watchDate",
"columnName": "watch_date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_playback_progress_event_guid",
"unique": true,
"columnNames": [
"event_guid"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_progress_event_guid` ON `${TABLE_NAME}` (`event_guid`)"
}
],
"foreignKeys": []
},
{
"tableName": "watchlist_item",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `event_guid` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventGuid",
"columnName": "event_guid",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_watchlist_item_event_guid",
"unique": true,
"columnNames": [
"event_guid"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_watchlist_item_event_guid` ON `${TABLE_NAME}` (`event_guid`)"
}
],
"foreignKeys": []
},
{
"tableName": "offline_event",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `event_guid` TEXT NOT NULL, `recording_id` INTEGER NOT NULL, `download_reference` INTEGER NOT NULL, `local_path` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventGuid",
"columnName": "event_guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recordingId",
"columnName": "recording_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "downloadReference",
"columnName": "download_reference",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localPath",
"columnName": "local_path",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_offline_event_event_guid",
"unique": true,
"columnNames": [
"event_guid"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_offline_event_event_guid` ON `${TABLE_NAME}` (`event_guid`)"
}
],
"foreignKeys": []
},
{
"tableName": "recommendation",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `event_guid` TEXT NOT NULL, `channel` TEXT NOT NULL, `programm_id` INTEGER NOT NULL, `dismissed` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventGuid",
"columnName": "event_guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "channel",
"columnName": "channel",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "programmId",
"columnName": "programm_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dismissed",
"columnName": "dismissed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_recommendation_event_guid_channel",
"unique": true,
"columnNames": [
"event_guid",
"channel"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recommendation_event_guid_channel` ON `${TABLE_NAME}` (`event_guid`, `channel`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd02652f6c74b9e0a5371cb34f851365')"
]
}
}

View file

@ -40,7 +40,7 @@ import de.nicidienase.chaosflix.common.userdata.entities.watchlist.WatchlistItem
OfflineEvent::class,
Recommendation::class
],
version = 7,
version = 8,
exportSchema = true)
@TypeConverters(Converters::class)
abstract class ChaosflixDatabase : RoomDatabase() {
@ -62,7 +62,8 @@ abstract class ChaosflixDatabase : RoomDatabase() {
ChaosflixDatabase::class.java, "mediaccc.de")
.addMigrations(
ChaosflixDatabase.migration_5_6,
ChaosflixDatabase.migration_6_7
ChaosflixDatabase.migration_6_7,
ChaosflixDatabase.migration_7_8
)
.fallbackToDestructiveMigrationFrom(1, 2, 3, 4)
.build()
@ -118,5 +119,12 @@ abstract class ChaosflixDatabase : RoomDatabase() {
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_recommendation_event_guid_channel ON recommendation (event_guid, channel)")
}
}
private val migration_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE event ADD COLUMN timelineUrl TEXT NOT NULL DEFAULT ''")
database.execSQL("ALTER TABLE event ADD COLUMN thumbnailsUrl TEXT NOT NULL DEFAULT ''")
}
}
}
}

View file

@ -0,0 +1,67 @@
package de.nicidienase.chaosflix.common.mediadata
import java.util.LinkedList
import okhttp3.OkHttpClient
import okhttp3.Request
class ThumbnailParser(private val okHttpClient: OkHttpClient) {
fun parse(uri: String): List<ThumbnailInfo> {
val request = Request.Builder().url(uri).build()
val lines = LinkedList(okHttpClient.newCall(request).execute().body()?.byteStream()?.bufferedReader()?.readLines())
val results = mutableListOf<Pair<String, String>>()
while (lines.peek().isNullOrBlank() || lines.peek().equals("WEBVTT")) {
lines.pop()
}
while (lines.isNotEmpty()) {
val times = lines.pop()
val datalines = mutableListOf<String>()
while (lines.peek().isNotEmpty()) {
datalines.add(lines.pop())
}
results.add(times to datalines.first())
while (!lines.isEmpty() && (lines.peek().isNullOrBlank() || lines.peek().equals("WEBVTT"))) {
lines.pop()
}
}
return convert(uri, results)
}
fun convert(uri: String, input: List<Pair<String, String>>): List<ThumbnailInfo> {
val baseUri = uri.substringBeforeLast("/")
return input.map {
val split = it.first.split(" --> ")
val uri = "$baseUri/${it.second}"
ThumbnailInfo(
timestampToMillis(split.first()),
timestampToMillis(split.last()),
uri
)
}
}
companion object {
internal fun timestampToMillis(timestamp: String): Long {
val split1 = timestamp.split(".")
var result = split1.last().toLong()
assert(split1.size == 2)
val split2 = split1.first().split(":").reversed()
var factor: Long = 1000L
for (item in split2) {
result += item.toLong() * factor
factor *= 60
}
return result
}
}
data class ThumbnailInfo(
val startTime: Long,
val endTime: Long,
val thumb: String
)
}

View file

@ -27,6 +27,10 @@ data class EventDto(
var thumbUrl: String? = "",
@SerializedName("poster_url")
var posterUrl: String = "",
@SerializedName("timeline_url")
var timelineUrl: String = "",
@SerializedName("thumbnails_url")
var thumbnailsUrl: String = "",
@SerializedName("frontend_link")
var frontendLink: String? = "",
var url: String = "",
@ -35,11 +39,7 @@ data class EventDto(
var recordings: List<RecordingDto>?,
var related: List<RelatedEventDto>?,
@SerializedName("promoted")
var isPromoted: Boolean = false,
@SerializedName("timeline_url")
var timelineUrl: String,
@SerializedName("thumbnails_url")
var thumbnailsUrl: String
var isPromoted: Boolean = false
) : Comparable<EventDto> {
var eventID: Long

View file

@ -44,26 +44,20 @@ data class Event(
var url: String = "",
var conferenceUrl: String = "",
var isPromoted: Boolean = false,
var viewCount: Int = 0,
var persons: Array<String>? = null,
var tags: Array<String>? = null,
@Ignore
var related: List<RelatedEvent>? = null,
@Ignore
var recordings: List<Recording>? = null
) : Parcelable, Comparable<Event> {
var recordings: List<Recording>? = null,
var timelineUrl: String = "",
var thumbnailsUrl: String = ""
) : Comparable<Event>, Parcelable {
@Ignore
var progress: Long = 0
override fun compareTo(other: Event): Int = title.compareTo(other.title)
fun getFilteredProperties(): List<String> {
return listOfNotNull(title, subtitle, description, getSpeakerString())
}
constructor(parcel: Parcel) : this(
parcel.readLong(),
parcel.readLong(),
@ -89,7 +83,17 @@ data class Event(
parcel.createStringArray(),
parcel.createStringArray(),
parcel.createTypedArrayList(RelatedEvent),
parcel.createTypedArrayList(Recording))
parcel.createTypedArrayList(Recording),
parcel.readString() ?: "",
parcel.readString() ?: "") {
progress = parcel.readLong()
}
override fun compareTo(other: Event): Int = title.compareTo(other.title)
fun getFilteredProperties(): List<String> {
return listOfNotNull(title, subtitle, description, getSpeakerString())
}
@Ignore
constructor(event: EventDto, conferenceId: Long = 0) : this(
@ -115,7 +119,9 @@ data class Event(
persons = event.persons,
tags = event.tags?.filterNotNull()?.toTypedArray(),
related = event.related?.map { RelatedEvent(event.eventID, it) },
recordings = event.recordings?.map { Recording(it) }
recordings = event.recordings?.map { Recording(it) },
timelineUrl = event.timelineUrl,
thumbnailsUrl = event.thumbnailsUrl
)
fun getExtendedDescription(): Spanned {
@ -157,6 +163,9 @@ data class Event(
parcel.writeStringArray(tags)
parcel.writeTypedList(related)
parcel.writeTypedList(recordings)
parcel.writeString(timelineUrl)
parcel.writeString(thumbnailsUrl)
parcel.writeLong(progress)
}
override fun describeContents(): Int {

View file

@ -17,7 +17,7 @@ class ApiFactory private constructor(apiUrl: String, cache: File? = null) {
private val chaosflixUserAgent: String by lazy { buildUserAgent() }
private val gsonConverterFactory: GsonConverterFactory by lazy { GsonConverterFactory.create(Gson()) }
private val client: OkHttpClient by lazy {
val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)

View file

@ -4,12 +4,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.nicidienase.chaosflix.common.ChaosflixDatabase
import de.nicidienase.chaosflix.common.mediadata.ThumbnailParser
import de.nicidienase.chaosflix.common.userdata.entities.progress.PlaybackProgress
import java.util.Date
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PlayerViewModel(val database: ChaosflixDatabase) : ViewModel() {
class PlayerViewModel(private val database: ChaosflixDatabase, private val thumbnailParser: ThumbnailParser) : ViewModel() {
private lateinit var eventGuid: String
@ -48,4 +50,13 @@ class PlayerViewModel(val database: ChaosflixDatabase) : ViewModel() {
}
}
}
suspend fun getThumbInfo(): List<ThumbnailParser.ThumbnailInfo>? = withContext(Dispatchers.IO) {
val event = database.eventDao().findEventByGuidSync(eventGuid)
return@withContext if (event != null && event.thumbnailsUrl.isNotBlank()) {
thumbnailParser.parse(event.thumbnailsUrl)
} else {
null
}
}
}

View file

@ -13,11 +13,12 @@ import de.nicidienase.chaosflix.common.ResourcesFacade
import de.nicidienase.chaosflix.common.SingletonHolder
import de.nicidienase.chaosflix.common.mediadata.MediaRepository
import de.nicidienase.chaosflix.common.mediadata.StreamingRepository
import de.nicidienase.chaosflix.common.mediadata.ThumbnailParser
import de.nicidienase.chaosflix.common.mediadata.network.ApiFactory
class ViewModelFactory private constructor(context: Context) : ViewModelProvider.Factory {
private val apiFactory = ApiFactory.getInstance(context.resources.getString(R.string.recording_url), context.cacheDir)
val apiFactory = ApiFactory.getInstance(context.resources.getString(R.string.recording_url), context.cacheDir)
private val database by lazy { ChaosflixDatabase.getInstance(context) }
private val streamingRepository by lazy { StreamingRepository(apiFactory.streamingApi) }
@ -31,6 +32,7 @@ class ViewModelFactory private constructor(context: Context) : ViewModelProvider
)
private val externalFilesDir = Environment.getExternalStorageDirectory()
private val resourcesFacade by lazy { ResourcesFacade(context) }
private val thumbnailParser by lazy { ThumbnailParser(apiFactory.client) }
val mediaRepository by lazy { MediaRepository(apiFactory.recordingApi, database) }
@Suppress("UNCHECKED_CAST")
@ -43,7 +45,7 @@ class ViewModelFactory private constructor(context: Context) : ViewModelProvider
streamingRepository,
preferencesManager,
resourcesFacade) as T
PlayerViewModel::class.java -> PlayerViewModel(database) as T
PlayerViewModel::class.java -> PlayerViewModel(database, thumbnailParser) as T
DetailsViewModel::class.java -> DetailsViewModel(
database,
offlineItemManager,

View file

@ -0,0 +1,48 @@
package de.nicidienase.chaosflix.common.mediadata
import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.hamcrest.number.OrderingComparison.greaterThan
import org.junit.Test
import org.junit.jupiter.api.Disabled
internal class ThumbnailParserTest {
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
}
@Disabled
@Test
fun test() {
val parse = ThumbnailParser("https://static.media.ccc.de/media/events/gpn/gpn19/67-hd.thumbnails.vtt", okHttpClient).parse()
assertThat(parse.size, greaterThan(999))
}
@Test
fun timestampConverter() {
assertThat(ThumbnailParser.timestampToMillis("00:00:00.500"), Matchers.`is`(500L))
}
@Test
fun timestampConverter2() {
assertThat(ThumbnailParser.timestampToMillis("00:00:00.500"), Matchers.`is`(500L))
}
@Test
fun timestampConverter3() {
assertThat(ThumbnailParser.timestampToMillis("00:01:00.000"), Matchers.`is`(60 * 1000L))
}
@Test
fun timestampConverter4() {
assertThat(ThumbnailParser.timestampToMillis("01:00:00.000"), Matchers.`is`(60 * 60 * 1000L))
}
@Test
fun timestampConverter5() {
assertThat(ThumbnailParser.timestampToMillis("03:05:11.111"), Matchers.`is`(11111111L))
}
}

View file

@ -2,52 +2,31 @@ package de.nicidienase.chaosflix.leanback.detail
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.media.MediaMetadataRetriever
import android.os.Build
import android.net.Uri
import android.util.Log
import androidx.leanback.media.PlaybackGlue
import androidx.leanback.widget.PlaybackSeekDataProvider
import de.nicidienase.chaosflix.common.AnalyticsWrapper
import de.nicidienase.chaosflix.common.AnalyticsWrapperImpl
import de.nicidienase.chaosflix.common.mediadata.network.ApiFactory
import com.bumptech.glide.Glide
import de.nicidienase.chaosflix.common.mediadata.ThumbnailParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
class ChaosflixSeekDataProvider(
val context: Context,
val length: Long,
val url: String
val thumbInfos: List<ThumbnailParser.ThumbnailInfo>
) : PlaybackSeekDataProvider() {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private var canceled = true
private var generateThumbsJob: Job? = null
private var mediaMetadataRetriever = MediaMetadataRetriever()
private val drawableMap: MutableMap<String, Bitmap> = mutableMapOf()
private val thumbnails: MutableMap<Long, Bitmap> = mutableMapOf()
private val dummyThumbnails: MutableMap<Long, Bitmap> = mutableMapOf()
private val calcTimes: MutableList<Long> = mutableListOf()
private val positions: LongArray by lazy {
val list = mutableListOf<Long>()
for (i in 0..(length * 1000) step calculateInterval(length)) {
list.add(i)
}
list.add(length * 1000)
Log.d(TAG, "Calculated ${list.size} steps")
return@lazy list.toLongArray()
(0..(length * 1000) step calculateInterval(length)).takeWhile { it < length * 1000 }.toLongArray()
}
private fun calculateInterval(length: Long): Long {
@ -59,199 +38,77 @@ class ChaosflixSeekDataProvider(
}
}
fun initialize() {
if (canceled) {
canceled = false
Log.d(TAG, "Retriever was canceled before, reinitializing")
mediaMetadataRetriever = MediaMetadataRetriever()
try {
mediaMetadataRetriever.setDataSource(url, mapOf("User-Agent" to ApiFactory.buildUserAgent()))
} catch (ex: Exception) {
Log.e(TAG, "Error: ${ex.message}", ex)
}
generateThumbs()
}
}
override fun getSeekPositions(): LongArray {
initialize()
return positions
}
private fun generateThumbs() {
generateThumbsJob = scope.launch {
for (i in positions.indices) {
if (!thumbnails.containsKey(positions[i])) {
val dummyThumbnail = createDummyThumbnail(i)
dummyThumbnails[positions[i]] = dummyThumbnail
}
}
Log.d(TAG, "Added Dummy-Thumbs")
yield()
for (i in positions.indices) {
if (!thumbnails.containsKey(positions[i])) {
val bitmap = createBitmapForIndex(i)
if (bitmap != null) {
thumbnails[positions[i]] = bitmap
}
yield()
}
}
}
}
override fun getThumbnail(index: Int, callback: ResultCallback?) {
val timestamp = positions[index]
when {
thumbnails.contains(positions[index]) -> {
thumbnails.contains(timestamp) -> {
Log.d(TAG, "Thumbnail match ($index/${positions.size})")
callback?.onThumbnailLoaded(thumbnails[positions[index]], index)
}
dummyThumbnails.contains(positions[index]) -> {
callback?.onThumbnailLoaded(dummyThumbnails[positions[index]], index)
scope.launch {
val thumb = createBitmapForIndex(index)
if (thumb != null) {
thumbnails[positions[index]] = thumb
withContext(Dispatchers.Main) {
callback?.onThumbnailLoaded(thumb, index)
}
}
}
callback?.onThumbnailLoaded(thumbnails[timestamp], index)
}
else -> scope.launch {
val dummyThumb = createDummyThumbnail(index)
dummyThumbnails[positions[index]] = dummyThumb
withContext(Dispatchers.Main) {
callback?.onThumbnailLoaded(dummyThumb, index)
}
scope.launch {
val thumb = createBitmapForIndex(index)
if (thumb != null) {
thumbnails[positions[index]] = thumb
try {
val thumbInfo = thumbInfos.first { it.startTime <= timestamp && timestamp <= it.endTime }
loadImageForUri(thumbInfo.thumb)?.let {
thumbnails[timestamp] = it
withContext(Dispatchers.Main) {
callback?.onThumbnailLoaded(thumb, index)
callback?.onThumbnailLoaded(it, index)
}
}
} catch (e: NoSuchElementException) {
Log.e(TAG, e.message, e)
}
}
}
}
private fun createBitmapForIndex(index: Int): Bitmap? {
if (canceled) {
return null
}
val startTime = System.currentTimeMillis()
val pos = positions[index] * 1000
val thumb: Bitmap = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
mediaMetadataRetriever.getScaledFrameAtTime(pos, MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
THUMB_WIDTH, THUMB_HEIGHT)
} else {
mediaMetadataRetriever.getFrameAtTime(pos, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
} ?: Bitmap.createBitmap(THUMB_WIDTH, THUMB_HEIGHT, Bitmap.Config.ARGB_8888)
} catch (ex: Exception) {
Log.e(TAG, "Error: ${ex.message}", ex)
return Bitmap.createBitmap(10, 10, Bitmap.Config.RGB_565)
}
Log.d(TAG, "Thumb size: ${thumb.width}x${thumb.height}")
val seconds = positions[index] / 1000
val time = formatTime(seconds)
drawStringToBitmap(thumb, time)
val duration = System.currentTimeMillis() - startTime
calcTimes.add(duration)
Log.d(TAG, "Adding Thumbnail ($index/${positions.size}) (took ${duration}ms)")
return thumb
}
private fun formatTime(seconds: Long): String {
val s = seconds % 60
val m = (seconds / 60) % 60
val h = seconds / 3600
return if (h > 0) {
String.format("%d:%02d:%02d", h, m, s)
} else {
String.format("%d:%02d", m, s)
}
}
private fun drawStringToBitmap(thumb: Bitmap, time: String) {
val canvas = Canvas(thumb)
val paint = Paint()
val textHeight = (canvas.height / 5).toFloat()
paint.textSize = textHeight
paint.color = Color.parseColor("#DD444444")
val textWidth = paint.measureText(time)
canvas.drawRoundRect(
0F,
canvas.height - (textHeight),
textWidth * 1.1F,
canvas.height.toFloat(),
textHeight * 0.2F,
textHeight * 0.2F,
paint
)
paint.color = Color.WHITE
canvas.drawText(time,
textHeight * 0.1F,
canvas.height.toFloat() - (textHeight * 0.1F),
paint)
}
private fun createDummyThumbnail(index: Int): Bitmap {
val seconds = positions[index] / 1000
val time = formatTime(seconds)
val bitmap = Bitmap.createBitmap(THUMB_WIDTH, THUMB_HEIGHT, Bitmap.Config.ARGB_8888)
drawStringToBitmap(bitmap, time)
return bitmap
}
override fun reset() {
Log.d(TAG, "SeekData reset Started")
canceled = true
GlobalScope.launch {
generateThumbsJob?.cancelAndJoin()
try {
mediaMetadataRetriever.release()
} catch (ex: Exception) {
Log.e(TAG, "Error: ${ex.message}", ex)
}
}
AnalyticsWrapperImpl.addAnalyticsEvent(AnalyticsWrapper.thumbnailsStatEvent,
mapOf(
"avg" to calcTimes.toLongArray().average().toLong().toString(),
"positions_count" to positions.size.toString(),
"calculated_count" to calcTimes.size.toString()
)
)
calcTimes.clear()
thumbnails.clear()
dummyThumbnails.clear()
drawableMap.clear()
Log.d(TAG, "SeekData reset Done")
super.reset()
}
private suspend fun loadImageForUri(uri: String): Bitmap? {
return withContext(Dispatchers.IO) {
try {
val imgUri = Uri.parse(uri)
val sourceBitmap = drawableMap[imgUri.path] ?: Glide.with(context)
.asBitmap()
.load(imgUri)
// .submit()
.submit(960, 990)
.get()
val params = imgUri.getQueryParameter("xywh")?.split(",")
return@withContext if (params?.size == 4) {
val (x, y, w, h) = params.map { it.toInt() }
Bitmap.createBitmap(sourceBitmap, x, y, w, h)
} else {
null
}
} catch (ex: RuntimeException) {
Log.e(TAG, ex.message, ex)
null
}
}
}
companion object {
private val TAG = ChaosflixSeekDataProvider::class.java.simpleName
private const val THUMB_WIDTH = 480
private const val THUMB_HEIGHT = 270
fun setSeekProvider(
glue: ChaosMediaPlayerGlue,
context: Context,
length: Long,
url: String
thumbs: List<ThumbnailParser.ThumbnailInfo>
) {
val chaosflixSeekDataProvider = ChaosflixSeekDataProvider(context, length, url)
val chaosflixSeekDataProvider = ChaosflixSeekDataProvider(context, length, thumbs)
if (glue.isPrepared) {
glue.seekProvider = chaosflixSeekDataProvider
glue.isSeekEnabled = true
chaosflixSeekDataProvider.initialize()
} else {
glue.addPlayerCallback(object : PlaybackGlue.PlayerCallback() {
override fun onPreparedStateChanged(glue: PlaybackGlue?) {
@ -260,7 +117,6 @@ class ChaosflixSeekDataProvider(
(glue as ChaosMediaPlayerGlue).seekProvider =
chaosflixSeekDataProvider
glue.isSeekEnabled = true
chaosflixSeekDataProvider.initialize()
}
}
})

View file

@ -196,7 +196,7 @@ class EventDetailsFragment : DetailsSupportFragment() {
val progress: Long? = state.data?.getLong(DetailsViewModel.PROGRESS)
if (recording != null) {
if (parcelable != null) {
prepareSeekProvider(parcelable.length, url ?: recording.recordingUrl)
prepareSeekProvider(parcelable.length)
}
preparePlayer(recording.recordingUrl)
playerAdapter.play()
@ -279,13 +279,17 @@ class EventDetailsFragment : DetailsSupportFragment() {
selectDialog?.show()
}
private fun prepareSeekProvider(length: Long, url: String) {
ChaosflixSeekDataProvider.setSeekProvider(
playerGlue,
requireContext(),
length,
url
)
private fun prepareSeekProvider(length: Long) {
lifecycleScope.launch {
playerViewModel.getThumbInfo()?.let {
ChaosflixSeekDataProvider.setSeekProvider(
playerGlue,
requireContext(),
length,
it
)
}
}
}
override fun onPause() {

View file

@ -0,0 +1,89 @@
package de.nicidienase.chaosflix.touch
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import de.nicidienase.chaosflix.common.mediadata.ThumbnailParser
import de.nicidienase.chaosflix.common.viewmodel.ViewModelFactory
import de.nicidienase.chaosflix.touch.databinding.FragmentThumbsTestBinding
import de.nicidienase.chaosflix.touch.databinding.ItemThumbBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ThumbsFragment : Fragment(R.layout.fragment_thumbs_test) {
private var uri: String = ""
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = FragmentThumbsTestBinding.bind(view)
val client = ViewModelFactory.getInstance(requireContext()).apiFactory.client
binding.thumbList.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
val thumbsAdapter = ThumbsAdapter()
binding.thumbList.adapter = thumbsAdapter
lifecycleScope.launch(Dispatchers.IO) {
val list = ThumbnailParser(client).parse(uri)
withContext(Dispatchers.Main) {
thumbsAdapter.submitList(list)
}
}
}
private val drawableMap: Map<String, Bitmap> = mutableMapOf()
private suspend fun loadImageForUri(uri: String): Drawable? {
return withContext(Dispatchers.IO) {
val imgUri = Uri.parse(uri)
val sourceBitmap = drawableMap[imgUri.path] ?: Glide.with(requireContext())
.asBitmap()
.load(imgUri)
.submit()
.get()
val params = imgUri.getQueryParameter("xywh")?.split(",")
return@withContext if (params?.size == 4) {
val (x, y, w, h) = params.map { it.toInt() }
BitmapDrawable(Bitmap.createBitmap(sourceBitmap, x, y, w, h))
} else {
null
}
}
}
private inner class ThumbsAdapter : ListAdapter<ThumbnailParser.ThumbnailInfo, ThumbsAdapter.ViewHolder>(object : DiffUtil.ItemCallback<ThumbnailParser.ThumbnailInfo>() {
override fun areItemsTheSame(oldItem: ThumbnailParser.ThumbnailInfo, newItem: ThumbnailParser.ThumbnailInfo): Boolean = oldItem === newItem
override fun areContentsTheSame(oldItem: ThumbnailParser.ThumbnailInfo, newItem: ThumbnailParser.ThumbnailInfo): Boolean = oldItem == newItem
}) {
inner class ViewHolder(val binding: ItemThumbBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemThumbBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
this@ThumbsFragment.lifecycleScope.launch {
val item = getItem(position)
holder.binding.apply {
thumbImage.setImageDrawable(loadImageForUri(item.thumb))
startTime.text = item.startTime.toString()
endTime.text = item.endTime.toString()
}
}
}
}
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView android:id="@+id/thumb_list"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/item_thumb"
xmlns:tools="http://schemas.android.com/tools" />

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/thumb_image"
android:layout_width="160dp"
android:layout_height="90dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/backgrounds/scenic" />
<TextView
android:id="@+id/start_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="123"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/thumb_image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/end_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="123"
app:layout_constraintStart_toEndOf="@id/thumb_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>