diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 96972995..5d343366 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -47,7 +47,7 @@ env:
jobs:
verify:
if: ${{ github.event_name != 'workflow_dispatch' || !inputs.publish_release || github.ref != 'refs/heads/main' }}
- runs-on: [self-hosted, Linux, X64, arko, android]
+ runs-on: [self-hosted, Linux, X64, ci-stack, android]
timeout-minutes: 30
steps:
- name: Checkout
@@ -116,7 +116,7 @@ jobs:
release:
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.publish_release }}
- runs-on: [self-hosted, Linux, X64, arko, android]
+ runs-on: [self-hosted, Linux, X64, ci-stack, android]
timeout-minutes: 35
permissions:
contents: write
@@ -238,7 +238,6 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
- target_commitish: ${{ github.sha }}
name: ${{ env.RELEASE_TAG }}
body_path: release-notes/${{ env.RELEASE_TAG }}.md
files: |
diff --git a/.gitignore b/.gitignore
index d90082ce..ed9ab42b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,8 +31,6 @@ google-services.json
# Android Profiling
*.hprof
.DS_Store
-__pycache__/
-*.py[cod]
# Local documentation
/docs/
diff --git a/README.md b/README.md
index b6e6f867..c4fbbb79 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@
Native Android client for TypeType.
+You want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md).
+
[

](LICENSE)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7d481b93..91643eae 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -26,8 +26,8 @@ android {
applicationId = "dev.typetype.android"
minSdk = 23
targetSdk = 37
- versionCode = 10821
- versionName = "1.8.0-beta.20"
+ versionCode = 10822
+ versionName = "1.8.1-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "TypeType")
}
diff --git a/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/23.json b/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/23.json
new file mode 100644
index 00000000..5d0e5f9c
--- /dev/null
+++ b/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/23.json
@@ -0,0 +1,2270 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 23,
+ "identityHash": "af45671844dd7704c642beea208fd8b5",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `displayName` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, `tagline` TEXT, `version` TEXT NOT NULL DEFAULT '', `revision` TEXT NOT NULL DEFAULT '', `apiVersion` INTEGER NOT NULL DEFAULT 0, `logoUrl` TEXT, `bannerUrl` TEXT, `supportedServicesCsv` TEXT NOT NULL DEFAULT '', `minAndroidClientVersion` TEXT, `registrationAllowed` INTEGER NOT NULL DEFAULT 0, `guestAllowed` INTEGER NOT NULL DEFAULT 0, `localLoginEnabled` INTEGER NOT NULL DEFAULT 1, `oidcEnabled` INTEGER NOT NULL DEFAULT 0, `oidcProviderName` TEXT, `oidcAutoRedirect` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginSupported` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginEnabled` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginReady` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginUnavailableReason` TEXT, `rssEnabled` INTEGER NOT NULL DEFAULT 0, `rssMaxFeedsPerUser` INTEGER NOT NULL DEFAULT 0, `rssMaxItems` INTEGER NOT NULL DEFAULT 0, `rssMinimumPollMinutes` INTEGER NOT NULL DEFAULT 0, `rssRateLimitPerMinute` INTEGER NOT NULL DEFAULT 0, `pushEnabled` INTEGER NOT NULL DEFAULT 0, `pushProvider` TEXT NOT NULL DEFAULT 'unifiedpush', `pushEventTypesCsv` TEXT NOT NULL DEFAULT '', `pushMaxDevicesPerAccount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseUrl",
+ "columnName": "baseUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "addedAt",
+ "columnName": "addedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tagline",
+ "columnName": "tagline",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "revision",
+ "columnName": "revision",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "apiVersion",
+ "columnName": "apiVersion",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "logoUrl",
+ "columnName": "logoUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bannerUrl",
+ "columnName": "bannerUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "supportedServicesCsv",
+ "columnName": "supportedServicesCsv",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "minAndroidClientVersion",
+ "columnName": "minAndroidClientVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "registrationAllowed",
+ "columnName": "registrationAllowed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "guestAllowed",
+ "columnName": "guestAllowed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "localLoginEnabled",
+ "columnName": "localLoginEnabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "oidcEnabled",
+ "columnName": "oidcEnabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "oidcProviderName",
+ "columnName": "oidcProviderName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "oidcAutoRedirect",
+ "columnName": "oidcAutoRedirect",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "youtubeRemoteLoginSupported",
+ "columnName": "youtubeRemoteLoginSupported",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "youtubeRemoteLoginEnabled",
+ "columnName": "youtubeRemoteLoginEnabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "youtubeRemoteLoginReady",
+ "columnName": "youtubeRemoteLoginReady",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "youtubeRemoteLoginUnavailableReason",
+ "columnName": "youtubeRemoteLoginUnavailableReason",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "rssEnabled",
+ "columnName": "rssEnabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssMaxFeedsPerUser",
+ "columnName": "rssMaxFeedsPerUser",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssMaxItems",
+ "columnName": "rssMaxItems",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssMinimumPollMinutes",
+ "columnName": "rssMinimumPollMinutes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssRateLimitPerMinute",
+ "columnName": "rssRateLimitPerMinute",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "pushEnabled",
+ "columnName": "pushEnabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "pushProvider",
+ "columnName": "pushProvider",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'unifiedpush'"
+ },
+ {
+ "fieldPath": "pushEventTypesCsv",
+ "columnName": "pushEventTypesCsv",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "pushMaxDevicesPerAccount",
+ "columnName": "pushMaxDevicesPerAccount",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "favorites",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `favoritedAtMillis` INTEGER NOT NULL, `title` TEXT NOT NULL DEFAULT '', `thumbnailUrl` TEXT NOT NULL DEFAULT '', `durationSeconds` INTEGER NOT NULL DEFAULT 0, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoUrl",
+ "columnName": "videoUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "favoritedAtMillis",
+ "columnName": "favoritedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "durationSeconds",
+ "columnName": "durationSeconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "channelName",
+ "columnName": "channelName",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "channelUrl",
+ "columnName": "channelUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "channelAvatarUrl",
+ "columnName": "channelAvatarUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "viewCount",
+ "columnName": "viewCount",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "videoUrl"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_favorites_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `durationSeconds` INTEGER NOT NULL, `progressSeconds` INTEGER NOT NULL, `watchedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `id`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelName",
+ "columnName": "channelName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelUrl",
+ "columnName": "channelUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "channelAvatarUrl",
+ "columnName": "channelAvatarUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "durationSeconds",
+ "columnName": "durationSeconds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progressSeconds",
+ "columnName": "progressSeconds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "watchedAtMillis",
+ "columnName": "watchedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_history_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_history_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ },
+ {
+ "name": "index_history_serverId_accountId_url",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_history_serverId_accountId_url` ON `${TABLE_NAME}` (`serverId`, `accountId`, `url`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "watch_later",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `addedAtMillis` INTEGER NOT NULL, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`, `url`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationSeconds",
+ "columnName": "durationSeconds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "addedAtMillis",
+ "columnName": "addedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelName",
+ "columnName": "channelName",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "channelUrl",
+ "columnName": "channelUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "channelAvatarUrl",
+ "columnName": "channelAvatarUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "viewCount",
+ "columnName": "viewCount",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "url"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_watch_later_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_watch_later_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, `videoCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`cacheKey`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "cacheKey",
+ "columnName": "cacheKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAtMillis",
+ "columnName": "createdAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoCount",
+ "columnName": "videoCount",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "cacheKey"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlists_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ },
+ {
+ "name": "index_playlists_serverId_accountId_id",
+ "unique": true,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_serverId_accountId_id` ON `${TABLE_NAME}` (`serverId`, `accountId`, `id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playlist_videos",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistCacheKey` TEXT NOT NULL, `playlistId` TEXT NOT NULL, `id` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `position` INTEGER NOT NULL, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`playlistCacheKey`, `id`), FOREIGN KEY(`playlistCacheKey`) REFERENCES `playlists`(`cacheKey`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "playlistCacheKey",
+ "columnName": "playlistCacheKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationSeconds",
+ "columnName": "durationSeconds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelName",
+ "columnName": "channelName",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "channelUrl",
+ "columnName": "channelUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "channelAvatarUrl",
+ "columnName": "channelAvatarUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "viewCount",
+ "columnName": "viewCount",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlistCacheKey",
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlist_videos_playlistCacheKey",
+ "unique": false,
+ "columnNames": [
+ "playlistCacheKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_videos_playlistCacheKey` ON `${TABLE_NAME}` (`playlistCacheKey`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "playlists",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "playlistCacheKey"
+ ],
+ "referencedColumns": [
+ "cacheKey"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "video_meta",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `channelAvatarUrl` TEXT NOT NULL, `viewCount` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoUrl",
+ "columnName": "videoUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelName",
+ "columnName": "channelName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelUrl",
+ "columnName": "channelUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelAvatarUrl",
+ "columnName": "channelAvatarUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewCount",
+ "columnName": "viewCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAtMillis",
+ "columnName": "updatedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "videoUrl"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_video_meta_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_video_meta_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "accounts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `publicUsername` TEXT, `role` TEXT, `avatarUrl` TEXT, `avatarType` TEXT, `avatarCode` TEXT, `isGuest` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicUsername",
+ "columnName": "publicUsername",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "role",
+ "columnName": "role",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "avatarUrl",
+ "columnName": "avatarUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "avatarType",
+ "columnName": "avatarType",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "avatarCode",
+ "columnName": "avatarCode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isGuest",
+ "columnName": "isGuest",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUsedAt",
+ "columnName": "lastUsedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionGeneration",
+ "columnName": "sessionGeneration",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_accounts_serverId",
+ "unique": false,
+ "columnNames": [
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_serverId` ON `${TABLE_NAME}` (`serverId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "downloads",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sessionGeneration` INTEGER NOT NULL DEFAULT 0, `requestId` TEXT NOT NULL, `workId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `quality` TEXT NOT NULL, `serverJobId` TEXT, `systemDownloadId` INTEGER, `fileName` TEXT, `status` TEXT NOT NULL, `progressPercent` INTEGER, `stage` TEXT, `errorMessage` TEXT, `cached` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `requestId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionGeneration",
+ "columnName": "sessionGeneration",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "requestId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workId",
+ "columnName": "workId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoUrl",
+ "columnName": "videoUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "quality",
+ "columnName": "quality",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverJobId",
+ "columnName": "serverJobId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "systemDownloadId",
+ "columnName": "systemDownloadId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fileName",
+ "columnName": "fileName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progressPercent",
+ "columnName": "progressPercent",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "stage",
+ "columnName": "stage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "errorMessage",
+ "columnName": "errorMessage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "cached",
+ "columnName": "cached",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAtMillis",
+ "columnName": "createdAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAtMillis",
+ "columnName": "updatedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "requestId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_downloads_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ },
+ {
+ "name": "index_downloads_workId",
+ "unique": true,
+ "columnNames": [
+ "workId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_workId` ON `${TABLE_NAME}` (`workId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "progress_outbox",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `positionMillis` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoUrl",
+ "columnName": "videoUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "positionMillis",
+ "columnName": "positionMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionGeneration",
+ "columnName": "sessionGeneration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAtMillis",
+ "columnName": "updatedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "videoUrl"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_progress_outbox_serverId_accountId_sessionGeneration",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "sessionGeneration"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_progress_outbox_serverId_accountId_sessionGeneration` ON `${TABLE_NAME}` (`serverId`, `accountId`, `sessionGeneration`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "library_sync_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `collection` TEXT NOT NULL, `refreshGeneration` INTEGER NOT NULL, `lastAttemptAtMillis` INTEGER NOT NULL, `lastSuccessAtMillis` INTEGER, `lastFailureAtMillis` INTEGER, `failureCode` TEXT, `failureStatusCode` INTEGER, `requestId` TEXT, PRIMARY KEY(`serverId`, `accountId`, `collection`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "collection",
+ "columnName": "collection",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "refreshGeneration",
+ "columnName": "refreshGeneration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastAttemptAtMillis",
+ "columnName": "lastAttemptAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSuccessAtMillis",
+ "columnName": "lastSuccessAtMillis",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastFailureAtMillis",
+ "columnName": "lastFailureAtMillis",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "failureCode",
+ "columnName": "failureCode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "failureStatusCode",
+ "columnName": "failureStatusCode",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "requestId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "collection"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_sync_state_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_sync_state_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "library_mutation_outbox",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `mutationKey` TEXT NOT NULL, `collection` TEXT NOT NULL, `kind` TEXT NOT NULL, `targetId` TEXT NOT NULL, `parentId` TEXT, `desiredPresent` INTEGER NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `channelAvatarUrl` TEXT NOT NULL, `viewCount` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL, `mutationVersion` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, `lastAttemptAtMillis` INTEGER, `attemptCount` INTEGER NOT NULL, `failureCode` TEXT, `failureStatusCode` INTEGER, `requestId` TEXT, PRIMARY KEY(`serverId`, `accountId`, `mutationKey`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mutationKey",
+ "columnName": "mutationKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "collection",
+ "columnName": "collection",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "targetId",
+ "columnName": "targetId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parentId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "desiredPresent",
+ "columnName": "desiredPresent",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationSeconds",
+ "columnName": "durationSeconds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelName",
+ "columnName": "channelName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelUrl",
+ "columnName": "channelUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelAvatarUrl",
+ "columnName": "channelAvatarUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewCount",
+ "columnName": "viewCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionGeneration",
+ "columnName": "sessionGeneration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mutationVersion",
+ "columnName": "mutationVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAtMillis",
+ "columnName": "createdAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAtMillis",
+ "columnName": "updatedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastAttemptAtMillis",
+ "columnName": "lastAttemptAtMillis",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "attemptCount",
+ "columnName": "attemptCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "failureCode",
+ "columnName": "failureCode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "failureStatusCode",
+ "columnName": "failureStatusCode",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "requestId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "mutationKey"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_mutation_outbox_serverId_accountId_sessionGeneration_state",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "sessionGeneration",
+ "state"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_mutation_outbox_serverId_accountId_sessionGeneration_state` ON `${TABLE_NAME}` (`serverId`, `accountId`, `sessionGeneration`, `state`)"
+ },
+ {
+ "name": "index_library_mutation_outbox_serverId_accountId_collection",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "collection"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_mutation_outbox_serverId_accountId_collection` ON `${TABLE_NAME}` (`serverId`, `accountId`, `collection`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "subscriptions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `avatarUrl` TEXT NOT NULL, `subscribedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `channelUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelUrl",
+ "columnName": "channelUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "avatarUrl",
+ "columnName": "avatarUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscribedAtMillis",
+ "columnName": "subscribedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "channelUrl"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_subscriptions_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_subscriptions_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playback_resume",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `positionMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoUrl",
+ "columnName": "videoUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "positionMillis",
+ "columnName": "positionMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAtMillis",
+ "columnName": "updatedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playback_resume_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_resume_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playback_queue",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `position` INTEGER NOT NULL, `currentIndex` INTEGER NOT NULL, `queueTitle` TEXT NOT NULL, `repeatMode` TEXT NOT NULL DEFAULT 'Off', `videoUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `channelName` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `position`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentIndex",
+ "columnName": "currentIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "queueTitle",
+ "columnName": "queueTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "repeatMode",
+ "columnName": "repeatMode",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'Off'"
+ },
+ {
+ "fieldPath": "videoUrl",
+ "columnName": "videoUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationSeconds",
+ "columnName": "durationSeconds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelName",
+ "columnName": "channelName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAtMillis",
+ "columnName": "updatedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "position"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playback_queue_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_queue_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "saved_public_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `publicPlaylistId` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `uploaderName` TEXT NOT NULL, `streamCount` INTEGER NOT NULL, `playlistType` TEXT NOT NULL, `savedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `id`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicPlaylistId",
+ "columnName": "publicPlaylistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploaderName",
+ "columnName": "uploaderName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "streamCount",
+ "columnName": "streamCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistType",
+ "columnName": "playlistType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAtMillis",
+ "columnName": "savedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_saved_public_playlists_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_saved_public_playlists_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ },
+ {
+ "name": "index_saved_public_playlists_serverId_accountId_url",
+ "unique": true,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_saved_public_playlists_serverId_accountId_url` ON `${TABLE_NAME}` (`serverId`, `accountId`, `url`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "feed_videos",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `feed` TEXT NOT NULL, `position` INTEGER NOT NULL, `videoUrl` TEXT NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `uploaderName` TEXT NOT NULL, `uploaderUrl` TEXT NOT NULL, `uploaderAvatarUrl` TEXT NOT NULL, `uploaderVerified` INTEGER NOT NULL, `durationSeconds` INTEGER NOT NULL, `isLive` INTEGER NOT NULL, `viewCount` INTEGER NOT NULL, `uploadedAtMillis` INTEGER NOT NULL, `isShortFormContent` INTEGER NOT NULL, `shortDescription` TEXT, `publishedAtMillis` INTEGER, `isPostLive` INTEGER NOT NULL, `isLiveContent` INTEGER NOT NULL, `requiresMembership` INTEGER NOT NULL, `savedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `feed`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "feed",
+ "columnName": "feed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoUrl",
+ "columnName": "videoUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoId",
+ "columnName": "videoId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploaderName",
+ "columnName": "uploaderName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploaderUrl",
+ "columnName": "uploaderUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploaderAvatarUrl",
+ "columnName": "uploaderAvatarUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploaderVerified",
+ "columnName": "uploaderVerified",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationSeconds",
+ "columnName": "durationSeconds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isLive",
+ "columnName": "isLive",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewCount",
+ "columnName": "viewCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploadedAtMillis",
+ "columnName": "uploadedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isShortFormContent",
+ "columnName": "isShortFormContent",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shortDescription",
+ "columnName": "shortDescription",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "publishedAtMillis",
+ "columnName": "publishedAtMillis",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isPostLive",
+ "columnName": "isPostLive",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isLiveContent",
+ "columnName": "isLiveContent",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresMembership",
+ "columnName": "requiresMembership",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAtMillis",
+ "columnName": "savedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "feed",
+ "videoUrl"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_feed_videos_serverId_accountId_feed",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "feed"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_videos_serverId_accountId_feed` ON `${TABLE_NAME}` (`serverId`, `accountId`, `feed`)"
+ },
+ {
+ "name": "index_feed_videos_serverId_accountId_feed_position",
+ "unique": true,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "feed",
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_videos_serverId_accountId_feed_position` ON `${TABLE_NAME}` (`serverId`, `accountId`, `feed`, `position`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "youtube_takeout_imports",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sessionGeneration` INTEGER NOT NULL, `requestId` TEXT NOT NULL, `workId` TEXT NOT NULL, `documentUri` TEXT NOT NULL, `displayName` TEXT NOT NULL, `sizeBytes` INTEGER, `serverJobId` TEXT, `status` TEXT NOT NULL, `phase` TEXT, `progressPercent` INTEGER, `previewSubscriptions` INTEGER, `previewPlaylists` INTEGER, `previewPlaylistItems` INTEGER, `previewFavorites` INTEGER, `previewWatchLater` INTEGER, `previewHistory` INTEGER, `importedCount` INTEGER, `skippedCount` INTEGER, `failedCount` INTEGER, `warningCount` INTEGER NOT NULL, `errorCount` INTEGER NOT NULL, `failureCode` TEXT, `failureRequestId` TEXT, `collectionsRefreshed` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `requestId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionGeneration",
+ "columnName": "sessionGeneration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "requestId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workId",
+ "columnName": "workId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "documentUri",
+ "columnName": "documentUri",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sizeBytes",
+ "columnName": "sizeBytes",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "serverJobId",
+ "columnName": "serverJobId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "phase",
+ "columnName": "phase",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "progressPercent",
+ "columnName": "progressPercent",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "previewSubscriptions",
+ "columnName": "previewSubscriptions",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "previewPlaylists",
+ "columnName": "previewPlaylists",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "previewPlaylistItems",
+ "columnName": "previewPlaylistItems",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "previewFavorites",
+ "columnName": "previewFavorites",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "previewWatchLater",
+ "columnName": "previewWatchLater",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "previewHistory",
+ "columnName": "previewHistory",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "importedCount",
+ "columnName": "importedCount",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "skippedCount",
+ "columnName": "skippedCount",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "failedCount",
+ "columnName": "failedCount",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "warningCount",
+ "columnName": "warningCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "errorCount",
+ "columnName": "errorCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "failureCode",
+ "columnName": "failureCode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "failureRequestId",
+ "columnName": "failureRequestId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "collectionsRefreshed",
+ "columnName": "collectionsRefreshed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAtMillis",
+ "columnName": "createdAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAtMillis",
+ "columnName": "updatedAtMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId",
+ "accountId",
+ "requestId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_youtube_takeout_imports_serverId_accountId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_youtube_takeout_imports_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)"
+ },
+ {
+ "name": "index_youtube_takeout_imports_workId",
+ "unique": true,
+ "columnNames": [
+ "workId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_youtube_takeout_imports_workId` ON `${TABLE_NAME}` (`workId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId",
+ "accountId"
+ ],
+ "referencedColumns": [
+ "serverId",
+ "accountId"
+ ]
+ }
+ ]
+ }
+ ],
+ "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, 'af45671844dd7704c642beea208fd8b5')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt b/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt
index fd55474e..467bed83 100644
--- a/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt
@@ -178,6 +178,33 @@ class TypeTypeDatabaseMigrationTest {
}
}
+ @Test
+ fun migratesVersion22WithPushCapabilityColumns() {
+ helper.createDatabase(DATABASE_NAME, 22).apply {
+ execSQL(
+ "INSERT INTO servers(id, baseUrl, displayName, addedAt) " +
+ "VALUES('instance-a', 'https://example.test/api', 'TypeType', 1)",
+ )
+ close()
+ }
+
+ database = Room.databaseBuilder(context, TypeTypeDatabase::class.java, DATABASE_NAME)
+ .addMigrations(*TypeTypeDatabaseMigrations.ALL)
+ .build()
+ val sqlite = requireNotNull(database).openHelper.writableDatabase
+
+ sqlite.query(
+ "SELECT pushEnabled, pushProvider, pushEventTypesCsv, pushMaxDevicesPerAccount " +
+ "FROM servers WHERE id = 'instance-a'",
+ ).use { cursor ->
+ assertTrue(cursor.moveToFirst())
+ assertEquals(0, cursor.getInt(0))
+ assertEquals("unifiedpush", cursor.getString(1))
+ assertEquals("", cursor.getString(2))
+ assertEquals(0, cursor.getInt(3))
+ }
+ }
+
@Test
fun migratesVersion13WithDurableScopedLibraryMutations() {
helper.createDatabase(DATABASE_NAME, 13).apply {
@@ -259,7 +286,7 @@ class TypeTypeDatabaseMigrationTest {
private companion object {
const val DATABASE_NAME = "migration-test.db"
const val FIRST_RETAINED_VERSION = 1
- const val CURRENT_VERSION = 22
+ const val CURRENT_VERSION = 23
const val DRAFT_VERSION_10_SERVERS =
"CREATE TABLE `servers` (" +
"`id` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `displayName` TEXT NOT NULL, " +
diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt
index 10bfd1da..82d253ae 100644
--- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
@@ -22,6 +23,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.graphics.asAndroidBitmap
@@ -33,6 +35,8 @@ import android.graphics.Bitmap
import java.io.File
import dev.typetype.android.R
import dev.typetype.android.domain.stream.StreamStoryboard
+import dev.typetype.android.feature.player.PlayerNavigationAvailability
+import dev.typetype.android.feature.player.PlayerNavigationControls
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.Timeline
@@ -110,7 +114,6 @@ class PlayerControlsLayoutTest {
.padding(top = 200.dp),
)
}
- }
composeRule.onNodeWithContentDescription(
composeRule.activity.getString(R.string.player_timeline),
@@ -173,6 +176,34 @@ class PlayerControlsLayoutTest {
).assertHeightIsAtLeast(64.dp)
}
+ @Test
+ fun navigationButtonsExposePreviousAndNextActions() {
+ var previousClicks = 0
+ var nextClicks = 0
+ composeRule.setContent {
+ PlayerControls(
+ player = controlsLayoutPlayer(),
+ title = "Navigation controls",
+ onNavigateBack = {},
+ navigation = PlayerNavigationControls(
+ availability = PlayerNavigationAvailability(previous = true, next = true),
+ onPrevious = { previousClicks++ },
+ onNext = { nextClicks++ },
+ ),
+ )
+ }
+
+ composeRule.onNodeWithContentDescription(
+ composeRule.activity.getString(R.string.player_previous_video),
+ ).assertIsEnabled().performClick()
+ composeRule.onNodeWithContentDescription(
+ composeRule.activity.getString(R.string.player_next_video),
+ ).assertIsEnabled().performClick()
+
+ assertEquals(1, previousClicks)
+ assertEquals(1, nextClicks)
+ }
+
private fun setControls(
width: Dp,
height: Dp,
diff --git a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt
index 4e0388f0..e11aadf9 100644
--- a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt
+++ b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt
@@ -60,7 +60,7 @@ import dev.typetype.android.data.subscriptions.SubscriptionEntity
FeedVideoEntity::class,
YoutubeTakeoutImportEntity::class,
],
- version = 22,
+ version = 23,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
diff --git a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt
index c668a217..d34d7fc3 100644
--- a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt
+++ b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt
@@ -158,6 +158,22 @@ object TypeTypeDatabaseMigrations {
}
}
+ val MIGRATION_22_23 = object : Migration(22, 23) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `servers` ADD COLUMN `pushEnabled` INTEGER NOT NULL DEFAULT 0")
+ db.execSQL(
+ "ALTER TABLE `servers` ADD COLUMN `pushProvider` TEXT NOT NULL DEFAULT 'unifiedpush'",
+ )
+ db.execSQL(
+ "ALTER TABLE `servers` ADD COLUMN `pushEventTypesCsv` TEXT NOT NULL DEFAULT ''",
+ )
+ db.execSQL(
+ "ALTER TABLE `servers` ADD COLUMN `pushMaxDevicesPerAccount` " +
+ "INTEGER NOT NULL DEFAULT 0",
+ )
+ }
+ }
+
val ALL = arrayOf(
MIGRATION_7_8,
MIGRATION_11_12,
@@ -170,5 +186,6 @@ object TypeTypeDatabaseMigrations {
MIGRATION_19_20,
MIGRATION_20_21,
MIGRATION_21_22,
+ MIGRATION_22_23,
)
}
diff --git a/app/src/main/java/dev/typetype/android/data/server/ServerEntity.kt b/app/src/main/java/dev/typetype/android/data/server/ServerEntity.kt
index 034cac3f..4d6ad048 100644
--- a/app/src/main/java/dev/typetype/android/data/server/ServerEntity.kt
+++ b/app/src/main/java/dev/typetype/android/data/server/ServerEntity.kt
@@ -34,6 +34,10 @@ data class ServerEntity(
@ColumnInfo(defaultValue = "0") val rssMaxItems: Int = 0,
@ColumnInfo(defaultValue = "0") val rssMinimumPollMinutes: Int = 0,
@ColumnInfo(defaultValue = "0") val rssRateLimitPerMinute: Int = 0,
+ @ColumnInfo(defaultValue = "0") val pushEnabled: Boolean = false,
+ @ColumnInfo(defaultValue = "unifiedpush") val pushProvider: String = "unifiedpush",
+ @ColumnInfo(defaultValue = "") val pushEventTypesCsv: String = "",
+ @ColumnInfo(defaultValue = "0") val pushMaxDevicesPerAccount: Int = 0,
) {
fun toDomain(): Server = Server(
id = id,
@@ -65,6 +69,12 @@ data class ServerEntity(
minimumPollMinutes = rssMinimumPollMinutes,
rateLimitPerMinute = rssRateLimitPerMinute,
),
+ push = dev.typetype.android.domain.server.PushCapability(
+ enabled = pushEnabled,
+ provider = pushProvider,
+ eventTypes = pushEventTypesCsv.split(',').mapNotNull { it.takeIf(String::isNotBlank) },
+ maxDevicesPerAccount = pushMaxDevicesPerAccount,
+ ),
)
companion object {
@@ -96,6 +106,10 @@ data class ServerEntity(
rssMaxItems = server.rss.maxItems,
rssMinimumPollMinutes = server.rss.minimumPollMinutes,
rssRateLimitPerMinute = server.rss.rateLimitPerMinute,
+ pushEnabled = server.push.enabled,
+ pushProvider = server.push.provider,
+ pushEventTypesCsv = server.push.eventTypes.joinToString(","),
+ pushMaxDevicesPerAccount = server.push.maxDevicesPerAccount,
)
}
}
diff --git a/app/src/main/java/dev/typetype/android/data/setup/SetupRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/setup/SetupRepositoryImpl.kt
index 33b5772c..6dcf6fb1 100644
--- a/app/src/main/java/dev/typetype/android/data/setup/SetupRepositoryImpl.kt
+++ b/app/src/main/java/dev/typetype/android/data/setup/SetupRepositoryImpl.kt
@@ -3,6 +3,7 @@ package dev.typetype.android.data.setup
import dev.typetype.android.data.network.RetrofitFactory
import dev.typetype.android.domain.server.Server
import dev.typetype.android.domain.server.ServerRepository
+import dev.typetype.android.domain.server.PushCapability
import dev.typetype.android.domain.server.RssCapability
import dev.typetype.android.domain.setup.ProbeResult
import dev.typetype.android.domain.setup.ServerAddress
@@ -61,6 +62,14 @@ class SetupRepositoryImpl @Inject constructor(
rateLimitPerMinute = it.rateLimitPerMinute,
)
} ?: RssCapability(),
+ push = resolved.instance.pushNotifications?.let {
+ PushCapability(
+ enabled = it.enabled,
+ provider = it.provider,
+ eventTypes = it.eventTypes,
+ maxDevicesPerAccount = it.maxDevicesPerAccount,
+ )
+ } ?: PushCapability(),
)
}
diff --git a/app/src/main/java/dev/typetype/android/domain/playback/PlaybackQueueState.kt b/app/src/main/java/dev/typetype/android/domain/playback/PlaybackQueueState.kt
index 540edf9b..ffc0a160 100644
--- a/app/src/main/java/dev/typetype/android/domain/playback/PlaybackQueueState.kt
+++ b/app/src/main/java/dev/typetype/android/domain/playback/PlaybackQueueState.kt
@@ -23,6 +23,15 @@ data class PlaybackQueueState(
val current: PlaybackQueueEntry?
get() = entries.getOrNull(currentIndex)
+ val previous: PlaybackQueueEntry?
+ get() = entries.getOrNull(currentIndex - 1) ?: if (
+ repeatMode == PlaybackRepeatMode.All && entries.size > 1
+ ) {
+ entries.lastOrNull()
+ } else {
+ null
+ }
+
val next: PlaybackQueueEntry?
get() = entries.getOrNull(currentIndex + 1) ?: if (
repeatMode == PlaybackRepeatMode.All && entries.size > 1
diff --git a/app/src/main/java/dev/typetype/android/domain/setup/SetupRepository.kt b/app/src/main/java/dev/typetype/android/domain/setup/SetupRepository.kt
index 44dfc7c5..f820da13 100644
--- a/app/src/main/java/dev/typetype/android/domain/setup/SetupRepository.kt
+++ b/app/src/main/java/dev/typetype/android/domain/setup/SetupRepository.kt
@@ -1,6 +1,7 @@
package dev.typetype.android.domain.setup
import dev.typetype.android.domain.server.Server
+import dev.typetype.android.domain.server.PushCapability
import dev.typetype.android.domain.server.RssCapability
interface SetupRepository {
@@ -33,4 +34,5 @@ data class ProbeResult(
val youtubeRemoteLoginReady: Boolean,
val youtubeRemoteLoginUnavailableReason: String?,
val rss: RssCapability,
+ val push: PushCapability,
)
diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt
index 939d3403..e1157d96 100644
--- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt
+++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt
@@ -14,7 +14,9 @@ class ChannelNotificationsController @Inject constructor(
suspend fun isAvailable(isSubscribed: Boolean): Boolean {
if (!isSubscribed) return false
val scope = runCatching { activeAccountScope.require() }.getOrNull() ?: return false
- return serverRepository.getServer(scope.serverId)?.push?.enabled == true
+ val cachedCapability = serverRepository.getServer(scope.serverId)?.push
+ if (cachedCapability?.enabled == true) return true
+ return runCatching { pushRepository.currentCapability().enabled }.getOrDefault(false)
}
suspend fun currentEnabled(channelUrl: String): Boolean? =
diff --git a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt
index 06f521e4..a1077461 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt
@@ -46,7 +46,7 @@ import kotlinx.coroutines.flow.Flow
import kotlin.math.roundToInt
@Composable
-fun LoadedPlayer(
+internal fun LoadedPlayer(
stream: Stream,
videoUrl: String,
resumeAtMillis: Long,
@@ -65,6 +65,7 @@ fun LoadedPlayer(
playlistActionInFlight: Boolean,
downloadInFlight: Boolean,
playbackQueue: PlaybackQueueState,
+ navigation: PlayerNavigationControls,
commentsFlow: Flow
>,
commentsRepository: CommentsRepository?,
prepareSabrPlayback: PrepareSabrPlayback,
@@ -267,6 +268,7 @@ fun LoadedPlayer(
danmakuState = danmakuState,
onDanmakuAction = onDanmakuAction,
hostTransitionProgress = hostTransitionProgress,
+ navigation = navigation,
modifier = Modifier.fillMaxSize(),
)
} else {
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt
index d6bda909..94ce3e84 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt
@@ -7,6 +7,8 @@ sealed interface PlayerAction {
data object OnToggleWatchLater : PlayerAction
data object OnRetry : PlayerAction
data object OnAdvanceQueue : PlayerAction
+ data object OnPlayPreviousVideo : PlayerAction
+ data object OnPlayNextVideo : PlayerAction
data object OnCancelQueueAutoplay : PlayerAction
data object OnToggleQueueAutoplayPause : PlayerAction
data class OnSetPlaybackBrightness(val percent: Int) : PlayerAction
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerLibraryStatusObserver.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerLibraryStatusObserver.kt
new file mode 100644
index 00000000..45e78daf
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerLibraryStatusObserver.kt
@@ -0,0 +1,39 @@
+package dev.typetype.android.feature.player
+
+import dev.typetype.android.domain.library.LibraryRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+
+internal class PlayerLibraryStatusObserver(
+ private val repository: LibraryRepository,
+ private val scope: CoroutineScope,
+ private val currentUrl: () -> String?,
+ private val onFavoriteChanged: (Boolean) -> Unit,
+ private val onWatchLaterChanged: (Boolean) -> Unit,
+) {
+ private var favoriteJob: Job? = null
+ private var watchLaterJob: Job? = null
+
+ fun observe(url: String) {
+ clear()
+ favoriteJob = scope.launch {
+ repository.observeIsFavorite(url).distinctUntilChanged().collect { isFavorite ->
+ if (currentUrl() == url) onFavoriteChanged(isFavorite)
+ }
+ }
+ watchLaterJob = scope.launch {
+ repository.observeIsInWatchLater(url).distinctUntilChanged().collect { inWatchLater ->
+ if (currentUrl() == url) onWatchLaterChanged(inWatchLater)
+ }
+ }
+ }
+
+ fun clear() {
+ favoriteJob?.cancel()
+ watchLaterJob?.cancel()
+ favoriteJob = null
+ watchLaterJob = null
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerNavigation.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerNavigation.kt
new file mode 100644
index 00000000..bd7a528e
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerNavigation.kt
@@ -0,0 +1,39 @@
+package dev.typetype.android.feature.player
+
+import dev.typetype.android.domain.playback.PlaybackQueueState
+
+internal data class PlayerNavigationAvailability(
+ val previous: Boolean,
+ val next: Boolean,
+)
+
+internal data class PlayerNavigationControls(
+ val availability: PlayerNavigationAvailability,
+ val onPrevious: () -> Unit,
+ val onNext: () -> Unit,
+) {
+ companion object {
+ val Disabled = PlayerNavigationControls(
+ availability = PlayerNavigationAvailability(previous = false, next = false),
+ onPrevious = {},
+ onNext = {},
+ )
+ }
+}
+
+internal fun playerNavigationAvailability(
+ queue: PlaybackQueueState,
+ relatedVideoCount: Int,
+ hasPreviousHistory: Boolean,
+): PlayerNavigationAvailability {
+ if (queue.isActive) {
+ return PlayerNavigationAvailability(
+ previous = queue.previous != null,
+ next = queue.next != null,
+ )
+ }
+ return PlayerNavigationAvailability(
+ previous = hasPreviousHistory,
+ next = relatedVideoCount > 0,
+ )
+}
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerNavigationController.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerNavigationController.kt
new file mode 100644
index 00000000..3beb5daa
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerNavigationController.kt
@@ -0,0 +1,56 @@
+package dev.typetype.android.feature.player
+
+import dev.typetype.android.domain.playback.PlaybackQueueState
+import dev.typetype.android.feature.player.host.PlayerHostController
+import dev.typetype.android.services.PlaybackQueueCoordinator
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+internal data class PlayerNavigationControllerState(
+ val queue: PlaybackQueueState,
+ val availability: PlayerNavigationAvailability,
+)
+
+internal class PlayerNavigationController(
+ private val hostController: PlayerHostController,
+ private val queueCoordinator: PlaybackQueueCoordinator,
+) {
+ private val relatedVideoCount = MutableStateFlow(0)
+
+ val state: Flow = combine(
+ queueCoordinator.state,
+ hostController.state,
+ relatedVideoCount,
+ ) { queue, host, relatedCount ->
+ PlayerNavigationControllerState(
+ queue = queue,
+ availability = playerNavigationAvailability(
+ queue = queue,
+ relatedVideoCount = relatedCount,
+ hasPreviousHistory = host.normalPlaybackHistory.isNotEmpty(),
+ ),
+ )
+ }.distinctUntilChanged()
+
+ fun setRelatedVideoCount(count: Int) {
+ relatedVideoCount.value = count.coerceAtLeast(0)
+ }
+
+ fun playPrevious() {
+ if (queueCoordinator.state.value.isActive) {
+ queueCoordinator.playPrevious()
+ } else {
+ hostController.goToPreviousVideo()
+ }
+ }
+
+ fun playNext(relatedVideoUrl: String?) {
+ if (queueCoordinator.state.value.isActive) {
+ queueCoordinator.advanceToNext()
+ } else {
+ relatedVideoUrl?.let(hostController::continueWithVideo)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt
index b8f7a904..4f2b2d7a 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt
@@ -178,6 +178,14 @@ fun PlayerScreen(
playlistActionInFlight = state.playlistActionInFlight,
downloadInFlight = state.downloadInFlight,
playbackQueue = state.playbackQueue,
+ navigation = PlayerNavigationControls(
+ availability = PlayerNavigationAvailability(
+ previous = state.canPlayPreviousVideo,
+ next = state.canPlayNextVideo,
+ ),
+ onPrevious = { onAction(PlayerAction.OnPlayPreviousVideo) },
+ onNext = { onAction(PlayerAction.OnPlayNextVideo) },
+ ),
commentsFlow = commentsFlow,
commentsRepository = commentsRepository,
prepareSabrPlayback = prepareSabrPlayback,
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerState.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerState.kt
index 3a8225e8..ab189b39 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/PlayerState.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerState.kt
@@ -28,6 +28,8 @@ data class PlayerState(
val playlistActionInFlight: Boolean = false,
val downloadInFlight: Boolean = false,
val playbackQueue: PlaybackQueueState = PlaybackQueueState(),
+ val canPlayPreviousVideo: Boolean = false,
+ val canPlayNextVideo: Boolean = false,
)
internal fun PlayerState.retryPlayback(): PlayerState =
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt
index 0d2dd90d..a34c9201 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt
@@ -62,13 +62,19 @@ class PlayerViewModel @Inject constructor(
val events: Flow = _events.receiveAsFlow()
val comments = playerCommentsFlow(commentsRepository, videoUrlFlow, viewModelScope)
private var loadStreamJob: Job? = null
- private var favoriteJob: Job? = null
- private var watchLaterJob: Job? = null
private val playerPreferences = PlayerPreferenceCoordinator(
preferencesRepository,
userSettingsRepository,
viewModelScope,
)
+ private val navigationController = PlayerNavigationController(playerHostController, playbackQueueCoordinator)
+ private val libraryStatusObserver = PlayerLibraryStatusObserver(
+ repository = libraryRepository,
+ scope = viewModelScope,
+ currentUrl = ::currentUrl,
+ onFavoriteChanged = { value -> _state.update { it.copy(isFavorited = value) } },
+ onWatchLaterChanged = { value -> _state.update { it.copy(isInWatchLater = value) } },
+ )
init {
viewModelScope.launch {
videoUrlFlow.collect { url ->
@@ -85,22 +91,28 @@ class PlayerViewModel @Inject constructor(
isInWatchLater = false,
downloadInFlight = false,
)
- }
+ }
if (url.isNullOrBlank()) {
loadStreamJob?.cancel()
- favoriteJob?.cancel()
- watchLaterJob?.cancel()
+ libraryStatusObserver.clear()
} else {
loadStream(url)
- observeLibraryStatus(url)
- }
+ libraryStatusObserver.observe(url)
}
+ navigationController.setRelatedVideoCount(0)
+ }
}
observePreferences()
viewModelScope.launch {
- playbackQueueCoordinator.state.collect { queue ->
- _state.update { it.copy(playbackQueue = queue) }
+ navigationController.state.collect { navigation ->
+ _state.update {
+ it.copy(
+ playbackQueue = navigation.queue,
+ canPlayPreviousVideo = navigation.availability.previous,
+ canPlayNextVideo = navigation.availability.next,
+ )
}
+ }
}
viewModelScope.launch {
libraryRepository.observePlaylists().collect { playlists ->
@@ -108,28 +120,6 @@ class PlayerViewModel @Inject constructor(
}
}
}
- private fun observeLibraryStatus(url: String) {
- favoriteJob?.cancel()
- favoriteJob = viewModelScope.launch {
- libraryRepository.observeIsFavorite(url)
- .distinctUntilChanged()
- .collect { isFavorite ->
- if (currentUrl() == url) {
- _state.update { it.copy(isFavorited = isFavorite) }
- }
- }
- }
- watchLaterJob?.cancel()
- watchLaterJob = viewModelScope.launch {
- libraryRepository.observeIsInWatchLater(url)
- .distinctUntilChanged()
- .collect { isInWatchLater ->
- if (currentUrl() == url) {
- _state.update { it.copy(isInWatchLater = isInWatchLater) }
- }
- }
- }
- }
private fun observePreferences() {
viewModelScope.launch {
playerPreferences.states.collect { prefs ->
@@ -153,6 +143,10 @@ class PlayerViewModel @Inject constructor(
PlayerAction.OnToggleWatchLater -> toggleWatchLater()
PlayerAction.OnRetry -> if (_state.value.stream == null) currentUrl()?.let(::loadStream) else _state.update(PlayerState::retryPlayback)
PlayerAction.OnAdvanceQueue -> playbackQueueCoordinator.playAutoplayNow()
+ PlayerAction.OnPlayPreviousVideo -> navigationController.playPrevious()
+ PlayerAction.OnPlayNextVideo -> navigationController.playNext(
+ _state.value.stream?.relatedStreams?.firstOrNull()?.url,
+ )
PlayerAction.OnCancelQueueAutoplay -> playbackQueueCoordinator.cancelAutoplay()
PlayerAction.OnToggleQueueAutoplayPause ->
playbackQueueCoordinator.toggleAutoplayPause()
@@ -281,6 +275,9 @@ class PlayerViewModel @Inject constructor(
playerStreamLoader.load(url).collect { update ->
if (currentUrl() != url) return@collect
_state.update { it.applyStreamUpdate(update, playerHostController.state.value) }
+ navigationController.setRelatedVideoCount(
+ _state.value.stream?.relatedStreams?.size ?: 0,
+ )
when (update) {
is PlayerStreamUpdate.PlaybackReady ->
launch { playerStreamLoader.record(url, update.loaded.stream) }
diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt
index d516d683..c755a6f2 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt
@@ -6,12 +6,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -21,6 +25,7 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import androidx.media3.ui.compose.state.rememberSeekBackButtonState
import androidx.media3.ui.compose.state.rememberSeekForwardButtonState
import dev.typetype.android.R
+import dev.typetype.android.feature.player.PlayerNavigationControls
@OptIn(markerClass = [UnstableApi::class])
@Composable
@@ -29,16 +34,17 @@ internal fun PlayerCenterControls(
isFullscreen: Boolean,
compact: Boolean = false,
expanded: Boolean = false,
+ navigation: PlayerNavigationControls,
modifier: Modifier = Modifier,
) {
val playPauseState = rememberPlayPauseButtonState(player)
val seekBackState = rememberSeekBackButtonState(player)
val seekForwardState = rememberSeekForwardButtonState(player)
val spacing = when {
- expanded -> 48.dp
- isFullscreen -> 44.dp
- compact -> 18.dp
- else -> 26.dp
+ expanded -> 32.dp
+ isFullscreen -> 30.dp
+ compact -> 12.dp
+ else -> 16.dp
}
Row(
@@ -46,6 +52,24 @@ internal fun PlayerCenterControls(
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalAlignment = Alignment.CenterVertically,
) {
+ PlayerCenterButton(
+ imageVector = Icons.Filled.SkipPrevious,
+ contentDescription = stringResource(R.string.player_previous_video),
+ enabled = navigation.availability.previous,
+ onClick = navigation.onPrevious,
+ buttonSize = when {
+ expanded -> 56.dp
+ isFullscreen -> 48.dp
+ compact -> 32.dp
+ else -> 40.dp
+ },
+ iconSize = when {
+ expanded -> 30.dp
+ isFullscreen -> 26.dp
+ compact -> 18.dp
+ else -> 22.dp
+ },
+ )
PlayerCenterButton(
iconRes = R.drawable.ic_rewind,
contentDescription = stringResource(R.string.player_rewind),
@@ -103,6 +127,24 @@ internal fun PlayerCenterControls(
else -> 26.dp
},
)
+ PlayerCenterButton(
+ imageVector = Icons.Filled.SkipNext,
+ contentDescription = stringResource(R.string.player_next_video),
+ enabled = navigation.availability.next,
+ onClick = navigation.onNext,
+ buttonSize = when {
+ expanded -> 56.dp
+ isFullscreen -> 48.dp
+ compact -> 32.dp
+ else -> 40.dp
+ },
+ iconSize = when {
+ expanded -> 30.dp
+ isFullscreen -> 26.dp
+ compact -> 18.dp
+ else -> 22.dp
+ },
+ )
}
}
@@ -132,3 +174,30 @@ private fun PlayerCenterButton(
)
}
}
+
+@Composable
+private fun PlayerCenterButton(
+ imageVector: ImageVector,
+ contentDescription: String,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ buttonSize: androidx.compose.ui.unit.Dp,
+ iconSize: androidx.compose.ui.unit.Dp,
+ prominent: Boolean = false,
+) {
+ val alpha = if (prominent) 0.58f else 0.34f
+ IconButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier
+ .size(buttonSize)
+ .background(Color.Black.copy(alpha = alpha), CircleShape),
+ ) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = contentDescription,
+ tint = Color.White.copy(alpha = if (enabled) 1f else 0.45f),
+ modifier = Modifier.size(iconSize),
+ )
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt
index ba03a8c9..e4d6c71e 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt
@@ -30,11 +30,12 @@ import androidx.media3.common.Player
import dev.typetype.android.R
import dev.typetype.android.domain.stream.SponsorBlockSegment
import dev.typetype.android.domain.stream.StreamStoryboard
+import dev.typetype.android.feature.player.PlayerNavigationControls
import dev.typetype.android.feature.player.state.ResizeMode
@Composable
@OptIn(ExperimentalLayoutApi::class)
-fun PlayerControls(
+internal fun PlayerControls(
player: Player,
storyboard: StreamStoryboard? = null,
title: String,
@@ -53,6 +54,7 @@ fun PlayerControls(
seekPreviewPositionMs: Long? = null,
timelineScrubbing: Boolean = false,
onTimelineScrubbingChange: (Boolean) -> Unit = {},
+ navigation: PlayerNavigationControls = PlayerNavigationControls.Disabled,
) {
BoxWithConstraints(modifier = modifier) {
val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT
@@ -105,6 +107,7 @@ fun PlayerControls(
expanded = expandedControls,
isFullscreen = isFullscreen,
compact = compactControls,
+ navigation = navigation,
modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG),
)
}
diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt
index 5e073802..d60da04d 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt
@@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt
index 5179c9f6..03f14d88 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt
@@ -34,6 +34,7 @@ import dev.typetype.android.feature.player.LoadSubtitleCues
import dev.typetype.android.feature.player.PlaybackCodecSupport
import dev.typetype.android.feature.player.PlayerDanmakuAction
import dev.typetype.android.feature.player.PlayerDanmakuState
+import dev.typetype.android.feature.player.PlayerNavigationControls
import dev.typetype.android.feature.player.SponsorBlockPlaybackPolicy
import dev.typetype.android.feature.player.key
import dev.typetype.android.feature.player.state.PlayerGestureState
@@ -78,6 +79,7 @@ internal fun PlayerSurfaceBox(
danmakuState: PlayerDanmakuState = PlayerDanmakuState(),
onDanmakuAction: (PlayerDanmakuAction) -> Unit = {},
hostTransitionProgress: () -> Float = { 0f },
+ navigation: PlayerNavigationControls,
) {
val activity = LocalActivity.current
val context = LocalContext.current
@@ -142,7 +144,6 @@ internal fun PlayerSurfaceBox(
}
PlayerAudioOnlyFailureEffect(audioOnlyState, audioOnlySnackbar)
-
val presentationState = rememberPresentationState(player, keepContentOnReset = true)
val surfaceKey = rememberPlayerSurfaceKey(stream.id)
val chromeModifier = Modifier.graphicsLayer {
@@ -270,6 +271,7 @@ internal fun PlayerSurfaceBox(
resizeMode = gestureState.resizeMode.value,
isPipAvailable = isPipAvailable,
chaptersAvailable = chapters.isNotEmpty(),
+ navigation = navigation,
modifier = chromeModifier,
)
diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt
index 79caa83e..80cdfdbe 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt
@@ -18,6 +18,7 @@ import dev.typetype.android.R
import androidx.media3.session.MediaController
import dev.typetype.android.domain.stream.SponsorBlockSegment
import dev.typetype.android.domain.stream.Stream
+import dev.typetype.android.feature.player.PlayerNavigationControls
import dev.typetype.android.feature.player.state.ResizeMode
@Composable
@@ -47,6 +48,7 @@ internal fun PlayerSurfaceChrome(
resizeMode: ResizeMode,
isPipAvailable: Boolean,
chaptersAvailable: Boolean,
+ navigation: PlayerNavigationControls,
modifier: Modifier = Modifier,
) {
Box(Modifier.fillMaxSize()) {
@@ -111,6 +113,7 @@ internal fun PlayerSurfaceChrome(
seekPreviewPositionMs = seekPreviewPositionMs,
onTimelineScrubbingChange = onTimelineScrubbingChange,
timelineScrubbing = timelineScrubbing,
+ navigation = navigation,
modifier = modifier.fillMaxSize(),
)
}
diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostController.kt b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostController.kt
index dcdc8d24..3f047217 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostController.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostController.kt
@@ -24,6 +24,7 @@ data class PlayerHostStateSnapshot(
val embeddedReturnVideoUrl: String? = null,
val embeddedReturnPositionMillis: Long? = null,
val embeddedReturnPlayWhenReady: Boolean = false,
+ val normalPlaybackHistory: List = emptyList(),
)
@Singleton
@@ -31,6 +32,10 @@ class PlayerHostController @Inject constructor(
private val playbackQueueCoordinator: PlaybackQueueController,
) {
+ private companion object {
+ const val MAX_NORMAL_PLAYBACK_HISTORY = 20
+ }
+
private val _state = MutableStateFlow(PlayerHostStateSnapshot())
val state: StateFlow = _state.asStateFlow()
@@ -48,6 +53,7 @@ class PlayerHostController @Inject constructor(
embeddedReturnVideoUrl = null,
embeddedReturnPositionMillis = null,
embeddedReturnPlayWhenReady = false,
+ normalPlaybackHistory = emptyList(),
)
}
playbackQueueCoordinator.clear()
@@ -55,6 +61,11 @@ class PlayerHostController @Inject constructor(
fun continueWithVideo(url: String) {
_state.update {
+ val history = if (it.videoUrl != null && it.videoUrl != url) {
+ (it.normalPlaybackHistory + it.videoUrl).takeLast(MAX_NORMAL_PLAYBACK_HISTORY)
+ } else {
+ it.normalPlaybackHistory
+ }
it.copy(
videoUrl = url,
resumePositionMillis = null,
@@ -65,9 +76,26 @@ class PlayerHostController @Inject constructor(
embeddedReturnVideoUrl = null,
embeddedReturnPositionMillis = null,
embeddedReturnPlayWhenReady = false,
+ normalPlaybackHistory = history,
+ )
+ }
+ playbackQueueCoordinator.clear()
+ }
+
+ fun goToPreviousVideo(): Boolean {
+ val previousUrl = _state.value.normalPlaybackHistory.lastOrNull() ?: return false
+ _state.update {
+ it.copy(
+ videoUrl = previousUrl,
+ resumePositionMillis = null,
+ initialPlayWhenReady = true,
+ requestStamp = it.requestStamp + 1,
+ playbackClearRequestStamp = null,
+ normalPlaybackHistory = it.normalPlaybackHistory.dropLast(1),
)
}
playbackQueueCoordinator.clear()
+ return true
}
fun openQueue(title: String, entries: List, shuffle: Boolean) {
@@ -86,6 +114,7 @@ class PlayerHostController @Inject constructor(
embeddedReturnVideoUrl = null,
embeddedReturnPositionMillis = null,
embeddedReturnPlayWhenReady = false,
+ normalPlaybackHistory = emptyList(),
)
}
}
@@ -106,6 +135,7 @@ class PlayerHostController @Inject constructor(
embeddedReturnVideoUrl = null,
embeddedReturnPositionMillis = null,
embeddedReturnPlayWhenReady = false,
+ normalPlaybackHistory = emptyList(),
)
}
playbackQueueCoordinator.clear()
@@ -128,6 +158,7 @@ class PlayerHostController @Inject constructor(
embeddedReturnVideoUrl = null,
embeddedReturnPositionMillis = null,
embeddedReturnPlayWhenReady = false,
+ normalPlaybackHistory = emptyList(),
)
}
}
@@ -265,6 +296,7 @@ class PlayerHostController @Inject constructor(
embeddedReturnVideoUrl = null,
embeddedReturnPositionMillis = null,
embeddedReturnPlayWhenReady = false,
+ normalPlaybackHistory = emptyList(),
)
}
playbackQueueCoordinator.clear()
diff --git a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerViewModel.kt b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerViewModel.kt
index adb2b92c..ac856aef 100644
--- a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerViewModel.kt
+++ b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerViewModel.kt
@@ -139,6 +139,7 @@ class AddServerViewModel @Inject constructor(
youtubeRemoteLoginReady = probe.youtubeRemoteLoginReady,
youtubeRemoteLoginUnavailableReason = probe.youtubeRemoteLoginUnavailableReason,
rss = probe.rss,
+ push = probe.push,
)
setupRepository.persistServer(server)
_state.update {
diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackQueueCoordinator.kt b/app/src/main/java/dev/typetype/android/services/PlaybackQueueCoordinator.kt
index ebe0355d..2c8ccf2d 100644
--- a/app/src/main/java/dev/typetype/android/services/PlaybackQueueCoordinator.kt
+++ b/app/src/main/java/dev/typetype/android/services/PlaybackQueueCoordinator.kt
@@ -158,6 +158,10 @@ class PlaybackQueueCoordinator @Inject internal constructor(
}
}
+ fun playPrevious() = mutableState.value.previous?.let { previous ->
+ play(mutableState.value.entries.indexOfFirst { it.videoUrl == previous.videoUrl })
+ }
+
fun playAutoplayNow() = autoplayController.playNow()
fun cancelAutoplay() = autoplayController.cancel()
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d07e1f2f..8296c3b8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -62,6 +62,7 @@
Short title
Title copied
Rewind
+ Previous video
Play / pause
Play
Pause
@@ -82,6 +83,7 @@
Playing
Paused
Forward
+ Next video
Fullscreen
Playback position
%1$s of %2$s
diff --git a/app/src/test/java/dev/typetype/android/data/server/ServerCapabilitiesTest.kt b/app/src/test/java/dev/typetype/android/data/server/ServerCapabilitiesTest.kt
index f12b684e..cbc0b9c1 100644
--- a/app/src/test/java/dev/typetype/android/data/server/ServerCapabilitiesTest.kt
+++ b/app/src/test/java/dev/typetype/android/data/server/ServerCapabilitiesTest.kt
@@ -80,6 +80,24 @@ class ServerCapabilitiesTest {
assertTrue(refreshed.rss.rateLimitPerMinute == 30)
}
+ @Test
+ fun enabledPushCapabilityPreservesProviderEventsAndLimit() {
+ val refreshed = server.withCapabilities(
+ decode(
+ BASE_INSTANCE.dropLast(1) +
+ ",\"pushNotifications\":{" +
+ "\"enabled\":true,\"provider\":\"unifiedpush\"," +
+ "\"eventTypes\":[\"new_video\",\"channel_live\"]," +
+ "\"maxDevicesPerAccount\":3}}",
+ ),
+ )
+
+ assertTrue(refreshed.push.enabled)
+ assertTrue(refreshed.push.provider == "unifiedpush")
+ assertTrue(refreshed.push.eventTypes == listOf("new_video", "channel_live"))
+ assertTrue(refreshed.push.maxDevicesPerAccount == 3)
+ }
+
private fun decode(value: String): InstanceResponse = json.decodeFromString(value)
private companion object {
diff --git a/app/src/test/java/dev/typetype/android/data/server/ServerEntityMappingTest.kt b/app/src/test/java/dev/typetype/android/data/server/ServerEntityMappingTest.kt
new file mode 100644
index 00000000..1f32db53
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/data/server/ServerEntityMappingTest.kt
@@ -0,0 +1,47 @@
+package dev.typetype.android.data.server
+
+import dev.typetype.android.domain.server.PushCapability
+import dev.typetype.android.domain.server.Server
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ServerEntityMappingTest {
+ @Test
+ fun preservesPushCapabilityAcrossRoomMapping() {
+ val server = Server(
+ id = "server",
+ baseUrl = "https://example.test/api/",
+ displayName = "Test",
+ addedAt = 1L,
+ push = PushCapability(
+ enabled = true,
+ provider = "unifiedpush",
+ eventTypes = listOf("new_video", "channel_live"),
+ maxDevicesPerAccount = 3,
+ ),
+ )
+
+ val restored = ServerEntity.fromDomain(server).toDomain()
+
+ assertTrue(restored.push.enabled)
+ assertEquals("unifiedpush", restored.push.provider)
+ assertEquals(listOf("new_video", "channel_live"), restored.push.eventTypes)
+ assertEquals(3, restored.push.maxDevicesPerAccount)
+ }
+
+ @Test
+ fun oldRowsRemainPushUnavailableByDefault() {
+ val restored = ServerEntity(
+ id = "server",
+ baseUrl = "https://example.test/api/",
+ displayName = "Test",
+ addedAt = 1L,
+ ).toDomain()
+
+ assertFalse(restored.push.enabled)
+ assertEquals("unifiedpush", restored.push.provider)
+ assertTrue(restored.push.eventTypes.isEmpty())
+ }
+}
diff --git a/app/src/test/java/dev/typetype/android/domain/playback/PlaybackQueueStateTest.kt b/app/src/test/java/dev/typetype/android/domain/playback/PlaybackQueueStateTest.kt
new file mode 100644
index 00000000..f5bf40ad
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/domain/playback/PlaybackQueueStateTest.kt
@@ -0,0 +1,51 @@
+package dev.typetype.android.domain.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class PlaybackQueueStateTest {
+ @Test
+ fun previousEntryUsesTheQueueOrder() {
+ val entries = listOf(entry("first"), entry("second"), entry("third"))
+
+ val state = PlaybackQueueState(entries = entries, currentIndex = 1)
+
+ assertSame(entries[0], state.previous)
+ assertSame(entries[2], state.next)
+ }
+
+ @Test
+ fun repeatAllWrapsPreviousFromTheFirstEntry() {
+ val entries = listOf(entry("first"), entry("second"))
+
+ val state = PlaybackQueueState(
+ entries = entries,
+ currentIndex = 0,
+ repeatMode = PlaybackRepeatMode.All,
+ )
+
+ assertSame(entries.last(), state.previous)
+ }
+
+ @Test
+ fun repeatOffHasNoPreviousAtTheStart() {
+ val state = PlaybackQueueState(
+ entries = listOf(entry("first"), entry("second")),
+ currentIndex = 0,
+ repeatMode = PlaybackRepeatMode.Off,
+ )
+
+ assertNull(state.previous)
+ assertEquals("second", state.next?.videoUrl)
+ }
+
+ private fun entry(url: String) = PlaybackQueueEntry(
+ videoUrl = url,
+ title = url,
+ thumbnailUrl = "",
+ durationSeconds = 1L,
+ channelName = "channel",
+ )
+}
diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlayerNavigationTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlayerNavigationTest.kt
new file mode 100644
index 00000000..f6ecc5b7
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/feature/player/PlayerNavigationTest.kt
@@ -0,0 +1,58 @@
+package dev.typetype.android.feature.player
+
+import dev.typetype.android.domain.playback.PlaybackQueueEntry
+import dev.typetype.android.domain.playback.PlaybackQueueState
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PlayerNavigationTest {
+ @Test
+ fun queueTargetsTakePrecedenceOverRelatedHistory() {
+ val state = PlaybackQueueState(
+ entries = listOf(entry("first"), entry("second"), entry("third")),
+ currentIndex = 1,
+ )
+
+ val availability = playerNavigationAvailability(
+ queue = state,
+ relatedVideoCount = 0,
+ hasPreviousHistory = false,
+ )
+
+ assertTrue(availability.previous)
+ assertTrue(availability.next)
+ }
+
+ @Test
+ fun normalPlaybackUsesHistoryAndRelatedVideos() {
+ val availability = playerNavigationAvailability(
+ queue = PlaybackQueueState(),
+ relatedVideoCount = 1,
+ hasPreviousHistory = true,
+ )
+
+ assertTrue(availability.previous)
+ assertTrue(availability.next)
+ }
+
+ @Test
+ fun normalPlaybackDisablesButtonsWithoutTargets() {
+ val availability = playerNavigationAvailability(
+ queue = PlaybackQueueState(),
+ relatedVideoCount = 0,
+ hasPreviousHistory = false,
+ )
+
+ assertFalse(availability.previous)
+ assertFalse(availability.next)
+ }
+
+ private fun entry(url: String) = PlaybackQueueEntry(
+ videoUrl = url,
+ title = url,
+ thumbnailUrl = "",
+ durationSeconds = 1L,
+ channelName = "channel",
+ )
+}
diff --git a/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostControllerTest.kt b/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostControllerTest.kt
index 17440d5b..a1720235 100644
--- a/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostControllerTest.kt
+++ b/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostControllerTest.kt
@@ -188,6 +188,20 @@ class PlayerHostControllerTest {
assertEquals("second", controller.state.value.videoUrl)
assertEquals(PlayerHostTarget.Expanded, controller.state.value.target)
}
+
+ @Test
+ fun `normal playback history restores previous videos in reverse order`() {
+ val controller = PlayerHostController(FakePlaybackQueueController())
+ controller.openVideo("first")
+ controller.continueWithVideo("second")
+ controller.continueWithVideo("third")
+
+ assertTrue(controller.goToPreviousVideo())
+ assertEquals("second", controller.state.value.videoUrl)
+ assertTrue(controller.goToPreviousVideo())
+ assertEquals("first", controller.state.value.videoUrl)
+ assertFalse(controller.goToPreviousVideo())
+ }
}
private class FakePlaybackQueueController : PlaybackQueueController {
diff --git a/gradle.properties b/gradle.properties
index 3abc70a6..408af2a5 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -17,7 +17,6 @@ kotlin.compiler.execution.strategy=in-process
android.useAndroidX=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
-android.enableResourceOptimizations=false
org.gradle.parallel=true
org.gradle.daemon=true
org.gradle.caching=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ff952c40..228c1d36 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,7 +4,7 @@ core = "1.19.0"
documentfile = "1.1.0"
gradle = "9.3.1"
junit = "4.13.2"
-kotlin = "2.4.10"
+kotlin = "2.4.20"
ksp = "2.3.9"
lifecycle = "2.11.0"
constraintlayout = "2.2.1"
@@ -37,7 +37,7 @@ androidxHilt = "1.4.0"
datastore = "1.2.1"
securityCrypto = "1.1.0"
tink = "1.23.0"
-okhttp = "5.4.0"
+okhttp = "5.5.0"
jsoup = "1.23.2"
unifiedpushConnector = "3.3.5"
aboutlibraries = "15.1.1"