From 6d5b9dc791527ac38a72b1ad1f72267f1d948b13 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 18 Apr 2020 15:53:39 +0200 Subject: [PATCH] load thumbs from api --- common/build.gradle | 4 +- .../8.json | 777 ++++++++++++++++++ .../chaosflix/common/ChaosflixDatabase.kt | 12 +- .../common/mediadata/ThumbnailParser.kt | 67 ++ .../mediadata/entities/recording/EventDto.kt | 10 +- .../entities/recording/persistence/Event.kt | 33 +- .../common/mediadata/network/ApiFactory.kt | 2 +- .../common/viewmodel/PlayerViewModel.kt | 13 +- .../common/viewmodel/ViewModelFactory.kt | 6 +- .../common/mediadata/ThumbnailParserTest.kt | 48 ++ .../detail/ChaosflixSeekDataProvider.kt | 230 +----- .../leanback/detail/EventDetailsFragment.kt | 20 +- .../chaosflix/touch/ThumbsFragment.kt | 89 ++ .../main/res/layout/fragment_thumbs_test.xml | 7 + touch/src/main/res/layout/item_thumb.xml | 38 + 15 files changed, 1136 insertions(+), 220 deletions(-) create mode 100644 common/schemas/de.nicidienase.chaosflix.common.ChaosflixDatabase/8.json create mode 100644 common/src/main/java/de/nicidienase/chaosflix/common/mediadata/ThumbnailParser.kt create mode 100644 common/src/test/java/de/nicidienase/chaosflix/common/mediadata/ThumbnailParserTest.kt create mode 100644 touch/src/main/java/de/nicidienase/chaosflix/touch/ThumbsFragment.kt create mode 100644 touch/src/main/res/layout/fragment_thumbs_test.xml create mode 100644 touch/src/main/res/layout/item_thumb.xml diff --git a/common/build.gradle b/common/build.gradle index 226764ea..babc15e0 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -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' diff --git a/common/schemas/de.nicidienase.chaosflix.common.ChaosflixDatabase/8.json b/common/schemas/de.nicidienase.chaosflix.common.ChaosflixDatabase/8.json new file mode 100644 index 00000000..9302245e --- /dev/null +++ b/common/schemas/de.nicidienase.chaosflix.common.ChaosflixDatabase/8.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/ChaosflixDatabase.kt b/common/src/main/java/de/nicidienase/chaosflix/common/ChaosflixDatabase.kt index b9698979..7ddfd274 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/ChaosflixDatabase.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/ChaosflixDatabase.kt @@ -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 ''") + } + } } } diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/ThumbnailParser.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/ThumbnailParser.kt new file mode 100644 index 00000000..ad6f8ebc --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/ThumbnailParser.kt @@ -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 { + + val request = Request.Builder().url(uri).build() + val lines = LinkedList(okHttpClient.newCall(request).execute().body()?.byteStream()?.bufferedReader()?.readLines()) + + val results = mutableListOf>() + while (lines.peek().isNullOrBlank() || lines.peek().equals("WEBVTT")) { + lines.pop() + } + while (lines.isNotEmpty()) { + val times = lines.pop() + val datalines = mutableListOf() + 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>): List { + 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 + ) +} diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/EventDto.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/EventDto.kt index 4c47c000..2de0b170 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/EventDto.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/EventDto.kt @@ -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?, var related: List?, @SerializedName("promoted") - var isPromoted: Boolean = false, - @SerializedName("timeline_url") - var timelineUrl: String, - @SerializedName("thumbnails_url") - var thumbnailsUrl: String + var isPromoted: Boolean = false ) : Comparable { var eventID: Long diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/Event.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/Event.kt index da8c430e..645c41d8 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/Event.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/Event.kt @@ -44,26 +44,20 @@ data class Event( var url: String = "", var conferenceUrl: String = "", var isPromoted: Boolean = false, - var viewCount: Int = 0, var persons: Array? = null, - var tags: Array? = null, @Ignore var related: List? = null, @Ignore - var recordings: List? = null -) : Parcelable, Comparable { + var recordings: List? = null, + var timelineUrl: String = "", + var thumbnailsUrl: String = "" +) : Comparable, Parcelable { @Ignore var progress: Long = 0 - override fun compareTo(other: Event): Int = title.compareTo(other.title) - - fun getFilteredProperties(): List { - 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 { + 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 { diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/ApiFactory.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/ApiFactory.kt index fea67d64..1ac18ce5 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/ApiFactory.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/network/ApiFactory.kt @@ -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) diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/PlayerViewModel.kt b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/PlayerViewModel.kt index e3adb033..68a9f3f1 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/PlayerViewModel.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/PlayerViewModel.kt @@ -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? = withContext(Dispatchers.IO) { + val event = database.eventDao().findEventByGuidSync(eventGuid) + return@withContext if (event != null && event.thumbnailsUrl.isNotBlank()) { + thumbnailParser.parse(event.thumbnailsUrl) + } else { + null + } + } } diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt index 1d7302b6..c8c47e20 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt @@ -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, diff --git a/common/src/test/java/de/nicidienase/chaosflix/common/mediadata/ThumbnailParserTest.kt b/common/src/test/java/de/nicidienase/chaosflix/common/mediadata/ThumbnailParserTest.kt new file mode 100644 index 00000000..1dcaef34 --- /dev/null +++ b/common/src/test/java/de/nicidienase/chaosflix/common/mediadata/ThumbnailParserTest.kt @@ -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)) + } +} diff --git a/leanback/src/main/java/de/nicidienase/chaosflix/leanback/detail/ChaosflixSeekDataProvider.kt b/leanback/src/main/java/de/nicidienase/chaosflix/leanback/detail/ChaosflixSeekDataProvider.kt index a2eabab7..82ee087d 100644 --- a/leanback/src/main/java/de/nicidienase/chaosflix/leanback/detail/ChaosflixSeekDataProvider.kt +++ b/leanback/src/main/java/de/nicidienase/chaosflix/leanback/detail/ChaosflixSeekDataProvider.kt @@ -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 ) : 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 = mutableMapOf() private val thumbnails: MutableMap = mutableMapOf() - private val dummyThumbnails: MutableMap = mutableMapOf() - - private val calcTimes: MutableList = mutableListOf() private val positions: LongArray by lazy { - val list = mutableListOf() - 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 ) { - 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() } } }) diff --git a/leanback/src/main/java/de/nicidienase/chaosflix/leanback/detail/EventDetailsFragment.kt b/leanback/src/main/java/de/nicidienase/chaosflix/leanback/detail/EventDetailsFragment.kt index 618df79e..50348e98 100644 --- a/leanback/src/main/java/de/nicidienase/chaosflix/leanback/detail/EventDetailsFragment.kt +++ b/leanback/src/main/java/de/nicidienase/chaosflix/leanback/detail/EventDetailsFragment.kt @@ -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() { diff --git a/touch/src/main/java/de/nicidienase/chaosflix/touch/ThumbsFragment.kt b/touch/src/main/java/de/nicidienase/chaosflix/touch/ThumbsFragment.kt new file mode 100644 index 00000000..47303376 --- /dev/null +++ b/touch/src/main/java/de/nicidienase/chaosflix/touch/ThumbsFragment.kt @@ -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 = 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(object : DiffUtil.ItemCallback() { + 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() + } + } + } + } +} diff --git a/touch/src/main/res/layout/fragment_thumbs_test.xml b/touch/src/main/res/layout/fragment_thumbs_test.xml new file mode 100644 index 00000000..ed052b45 --- /dev/null +++ b/touch/src/main/res/layout/fragment_thumbs_test.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/touch/src/main/res/layout/item_thumb.xml b/touch/src/main/res/layout/item_thumb.xml new file mode 100644 index 00000000..86e4b2bc --- /dev/null +++ b/touch/src/main/res/layout/item_thumb.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file