From ff3f87d001dfbe8453556dd2aefdc52e96cc369c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 21 Sep 2026 11:15:56 +0200 Subject: [PATCH] build: publish mobile beta 1.8.1-beta.1 --- .github/workflows/android.yml | 5 +- .gitignore | 2 - README.md | 2 + app/build.gradle.kts | 4 +- .../23.json | 2270 +++++++++++++++++ .../database/TypeTypeDatabaseMigrationTest.kt | 29 +- .../components/PlayerControlsLayoutTest.kt | 33 +- .../android/data/database/TypeTypeDatabase.kt | 2 +- .../database/TypeTypeDatabaseMigrations.kt | 17 + .../android/data/server/ServerEntity.kt | 14 + .../android/data/setup/SetupRepositoryImpl.kt | 9 + .../domain/playback/PlaybackQueueState.kt | 9 + .../android/domain/setup/SetupRepository.kt | 2 + .../channel/ChannelNotificationsController.kt | 4 +- .../android/feature/player/LoadedPlayer.kt | 4 +- .../android/feature/player/PlayerAction.kt | 2 + .../player/PlayerLibraryStatusObserver.kt | 39 + .../feature/player/PlayerNavigation.kt | 39 + .../player/PlayerNavigationController.kt | 56 + .../android/feature/player/PlayerScreen.kt | 8 + .../android/feature/player/PlayerState.kt | 2 + .../android/feature/player/PlayerViewModel.kt | 59 +- .../player/components/PlayerCenterControls.kt | 77 +- .../player/components/PlayerControls.kt | 5 +- .../player/components/PlayerGestureLayer.kt | 1 - .../player/components/PlayerSurfaceBox.kt | 4 +- .../player/components/PlayerSurfaceChrome.kt | 3 + .../player/host/PlayerHostController.kt | 32 + .../setup/addserver/AddServerViewModel.kt | 1 + .../services/PlaybackQueueCoordinator.kt | 4 + app/src/main/res/values/strings.xml | 2 + .../data/server/ServerCapabilitiesTest.kt | 18 + .../data/server/ServerEntityMappingTest.kt | 47 + .../domain/playback/PlaybackQueueStateTest.kt | 51 + .../feature/player/PlayerNavigationTest.kt | 58 + .../player/host/PlayerHostControllerTest.kt | 14 + gradle.properties | 1 - gradle/libs.versions.toml | 4 +- 38 files changed, 2880 insertions(+), 53 deletions(-) create mode 100644 app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/23.json create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerLibraryStatusObserver.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerNavigation.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerNavigationController.kt create mode 100644 app/src/test/java/dev/typetype/android/data/server/ServerEntityMappingTest.kt create mode 100644 app/src/test/java/dev/typetype/android/domain/playback/PlaybackQueueStateTest.kt create mode 100644 app/src/test/java/dev/typetype/android/feature/player/PlayerNavigationTest.kt 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). +
[GPL v3](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"