diff --git a/.github/workflows/android-beta.yml b/.github/workflows/android-beta.yml
index 524fe121..a544a1a5 100644
--- a/.github/workflows/android-beta.yml
+++ b/.github/workflows/android-beta.yml
@@ -45,7 +45,7 @@ env:
jobs:
release:
- runs-on: [self-hosted, Linux, X64, arko, android]
+ runs-on: [self-hosted, Linux, X64, ci-stack, android]
timeout-minutes: 35
permissions:
contents: write
@@ -147,6 +147,7 @@ jobs:
- name: Verify and prepare beta APK
run: |
+ export PATH="$JAVA_HOME/bin:$PATH"
mkdir -p artifacts
apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)"
test -n "$apk"
@@ -182,13 +183,24 @@ jobs:
compression-level: 0
retention-days: 30
+ - name: Prepare beta release notes
+ if: ${{ github.event_name != 'pull_request' }}
+ env:
+ HEAD_MESSAGE: ${{ github.event.head_commit.message }}
+ run: |
+ if [ -f "release-notes/${RELEASE_TAG}.md" ]; then
+ cp "release-notes/${RELEASE_TAG}.md" release-body.md
+ else
+ printf '%s\n' "${HEAD_MESSAGE:-Beta build from the development branch.}" > release-body.md
+ fi
+
- name: Publish GitHub prerelease
if: ${{ github.event_name != 'pull_request' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
name: TypeType Android ${{ env.RELEASE_VERSION }}
- body: ${{ github.event.head_commit.message }}
+ body_path: release-body.md
prerelease: true
make_latest: false
generate_release_notes: false
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 96972995..4036e3c0 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
@@ -207,6 +207,7 @@ jobs:
- name: Prepare APK artifact
run: |
+ export PATH="$JAVA_HOME/bin:$PATH"
mkdir -p artifacts
apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)"
test -n "$apk"
@@ -238,7 +239,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..b50c2215 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@
Native Android client for TypeType.
+You want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md).
+
[

](LICENSE)
@@ -23,8 +25,8 @@
TypeType Android is the native client for
[TypeType](https://github.com/TypeType-Video/TypeType), a self-hosted video
-platform. The app is in beta, supports Android 6.0 and newer, and does not
-require Google Play Services.
+platform. The app supports Android 6.0 and newer and does not require Google
+Play Services.
TypeType Android uses the TypeType Server selected during setup for extraction,
playback sessions, recommendations, synchronization, and downloads.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fe80e746..72c76786 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 = 10820
- versionName = "1.8.0"
+ versionCode = 10900
+ versionName = "1.9.0"
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/PlayerInteractionRowComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt
index a4ebd3dc..2f301c60 100644
--- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt
@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.typetype.android.R
import org.junit.Assert.assertEquals
+import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
@@ -24,6 +25,10 @@ class PlayerInteractionRowComposeTest {
@Test
fun tabletShowsLabelsAndLargerTouchTargets() {
+ assumeTrue(
+ "tablet interaction row needs a 600dp wide device",
+ composeRule.activity.resources.configuration.smallestScreenWidthDp >= 600,
+ )
var commentClicks = 0
setActions(720.dp) { commentClicks += 1 }
diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt
index d1958adc..20074a45 100644
--- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt
@@ -21,7 +21,7 @@ class CommentBodyTest {
@Test
fun longCommentCollapsesAndExpandsWithReadMore() {
- val suffix = " more comment detail".repeat(60)
+ val suffix = " more comment detail".repeat(30)
val comment = Comment(
id = "comment",
text = "Long comment starts here$suffix",
@@ -47,7 +47,9 @@ class CommentBodyTest {
}
composeRule.onNodeWithText("Read more").assertExists().performClick()
+ composeRule.waitForIdle()
composeRule.onNodeWithText("Show less").assertExists().performClick()
+ composeRule.waitForIdle()
composeRule.onNodeWithText("Read more").assertExists()
}
}
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..0b6584d5 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
@@ -42,6 +46,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import java.lang.reflect.Proxy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -110,7 +115,6 @@ class PlayerControlsLayoutTest {
.padding(top = 200.dp),
)
}
- }
composeRule.onNodeWithContentDescription(
composeRule.activity.getString(R.string.player_timeline),
@@ -161,6 +165,10 @@ class PlayerControlsLayoutTest {
@Test
fun tabletControlsHaveLargerTargetsWithoutOverlapping() {
+ assumeTrue(
+ "tablet player controls need a 600dp wide device",
+ composeRule.activity.resources.configuration.smallestScreenWidthDp >= 600,
+ )
setControls(720.dp, 405.dp, 720)
assertControlsDoNotOverlap()
composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG)
@@ -173,6 +181,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/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt
index 3da84b1a..de2b9a6d 100644
--- a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt
@@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex
+import androidx.compose.ui.test.performScrollToNode
import dev.typetype.android.core.ui.theme.TypeTypeTheme
import dev.typetype.android.domain.preferences.AccentColor
import dev.typetype.android.domain.preferences.AppPreferences
@@ -109,8 +110,8 @@ class AppearanceScreenTest {
}
}
- composeRule.onAllNodes(hasScrollAction())[0].performScrollToIndex(11)
- composeRule.onNode(isSelectable() and hasText("Red"))
- .assertIsEnabled()
+ val accentCell = isSelectable() and hasText("Red")
+ composeRule.onAllNodes(hasScrollAction())[0].performScrollToNode(accentCell)
+ composeRule.onNode(accentCell).assertIsEnabled()
}
}
diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt
index fa0784ff..eb67587d 100644
--- a/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt
@@ -22,6 +22,8 @@ class ImportDataScreenTest {
fun restoreRequiresASelectedArchive() {
setScreen(ImportDataState())
+ composeRule.onNode(hasScrollAction())
+ .performScrollToNode(hasText("Restore backup"))
composeRule.onNodeWithText("Restore backup").assertIsNotEnabled()
}
diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt
index e9179b4c..3cd6ee55 100644
--- a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt
@@ -30,6 +30,9 @@ class PlayerSettingsCaptionStyleTest {
}
}
+ composeRule.onNode(hasScrollAction())
+ .performScrollToNode(hasText("Show advanced player settings"))
+ composeRule.onNodeWithText("Show advanced player settings").performClick()
composeRule.onNode(hasScrollAction())
.performScrollToNode(hasText("Text size"))
composeRule.onNodeWithText("Text size").performClick()
diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt
index f30fa374..45052702 100644
--- a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt
@@ -30,6 +30,9 @@ class PlayerSettingsDanmakuTest {
}
}
+ composeRule.onNode(hasScrollAction())
+ .performScrollToNode(hasText("Show advanced player settings"))
+ composeRule.onNodeWithText("Show advanced player settings").performClick()
composeRule.onNode(hasScrollAction())
.performScrollToNode(hasText("Show bullet comments"))
composeRule.onNodeWithText("Show bullet comments")
diff --git a/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt b/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt
index 77045353..8749203b 100644
--- a/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt
+++ b/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt
@@ -82,7 +82,7 @@ class PlaybackBackgroundTransitionTest {
}
private fun waitForMainThread(condition: () -> Boolean): Boolean {
- val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10)
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30)
while (System.nanoTime() < deadline) {
if (readOnMainThread(condition)) return true
Thread.sleep(50)
diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt
index b43f93d3..aac890b3 100644
--- a/app/src/main/java/dev/typetype/android/AppNavHost.kt
+++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt
@@ -27,6 +27,8 @@ import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute
import dev.typetype.android.core.ui.navigation.PublicPlaylistRoute
import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute
import dev.typetype.android.core.ui.share.LocalServerBaseUrl
+import dev.typetype.android.feature.player.LocalPlaybackPrewarm
+import dev.typetype.android.feature.player.PlaybackPrewarm
import dev.typetype.android.core.ui.navigation.AppearanceRoute
import dev.typetype.android.core.ui.navigation.ContentSettingsRoute
import dev.typetype.android.core.ui.navigation.HomeRoute
@@ -61,7 +63,11 @@ import dev.typetype.android.feature.subscriptions.SubscriptionsRoute as Subscrip
import kotlinx.coroutines.flow.collectLatest
@Composable
-fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) {
+fun AppNavHost(
+ startRoute: Any,
+ mainViewModel: MainViewModel,
+ playbackPrewarm: PlaybackPrewarm? = null,
+) {
val navController: NavHostController = rememberNavController()
val playerHostController = remember { mainViewModel.playerHostController }
val currentServer by mainViewModel.currentServer.collectAsStateWithLifecycle()
@@ -133,6 +139,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) {
CompositionLocalProvider(
LocalServerBaseUrl provides currentServer?.baseUrl,
LocalDeArrowBranding provides deArrowEnvironment,
+ LocalPlaybackPrewarm provides playbackPrewarm,
) {
AppShell(
navController = navController,
diff --git a/app/src/main/java/dev/typetype/android/MainActivity.kt b/app/src/main/java/dev/typetype/android/MainActivity.kt
index 82093334..30b2839f 100644
--- a/app/src/main/java/dev/typetype/android/MainActivity.kt
+++ b/app/src/main/java/dev/typetype/android/MainActivity.kt
@@ -36,6 +36,7 @@ import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl
import dev.typetype.android.domain.navigation.resolveSharedVideoUrl
import dev.typetype.android.domain.session.ActiveSessionRepository
import dev.typetype.android.feature.player.components.PIP_ACTION_AUDIO_ONLY
+import dev.typetype.android.feature.player.PlaybackPrewarm
import dev.typetype.android.feature.player.components.PIP_ACTION_PLAY_PAUSE
import dev.typetype.android.feature.player.components.PictureInPictureActionStateOwner
import dev.typetype.android.feature.player.components.updatePictureInPicturePlaybackAction
@@ -56,6 +57,9 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner {
@Inject
lateinit var oidcCallbackRelay: OidcCallbackRelay
+ @Inject
+ lateinit var playbackPrewarm: PlaybackPrewarm
+
private val viewModel: MainViewModel by viewModels()
private var activityReportingJob: Job? = null
private var pipAudioOnlyAvailable = false
@@ -105,7 +109,11 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner {
report = pendingCrashReport,
onContinue = viewModel::continueAfterCrash,
)
- startRoute != null -> AppNavHost(startRoute = startRoute, mainViewModel = viewModel)
+ startRoute != null -> AppNavHost(
+ startRoute = startRoute,
+ mainViewModel = viewModel,
+ playbackPrewarm = playbackPrewarm,
+ )
else -> FullScreenLoader()
}
}
diff --git a/app/src/main/java/dev/typetype/android/MainViewModel.kt b/app/src/main/java/dev/typetype/android/MainViewModel.kt
index bfaeaaf7..6ffc47c9 100644
--- a/app/src/main/java/dev/typetype/android/MainViewModel.kt
+++ b/app/src/main/java/dev/typetype/android/MainViewModel.kt
@@ -27,6 +27,7 @@ import dev.typetype.android.domain.subscriptions.SubscriptionsRepository
import dev.typetype.android.domain.usersettings.UserSettings
import dev.typetype.android.domain.usersettings.UserSettingsRepository
import dev.typetype.android.feature.player.host.PlayerHostController
+import dev.typetype.android.data.notifications.LocalNotificationScheduler
import dev.typetype.android.services.push.PushRegistrationManager
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
@@ -65,6 +66,7 @@ class MainViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
private val activeAccountScope: ActiveAccountScope,
private val pushRegistrationManager: PushRegistrationManager,
+ private val localNotificationScheduler: LocalNotificationScheduler,
private val startupLandingStore: StartupLandingStore,
private val playbackResumeRepository: PlaybackResumeRepository,
private val playbackQueueRepository: PlaybackQueueRepository,
@@ -184,10 +186,15 @@ class MainViewModel @Inject constructor(
launch { profileRepository.refresh() }
launch { subscriptionsRepository.refresh() }
launch { libraryRepository.resumePendingWrites() }
- launch { pushRegistrationManager.reconcileRegistration() }
+ launch { reconcileNotificationDelivery() }
}
}
+ private suspend fun reconcileNotificationDelivery() {
+ pushRegistrationManager.reconcileRegistration()
+ localNotificationScheduler.sync()
+ }
+
private companion object {
const val STARTUP_TIMEOUT_MS = 4_000L
const val SESSION_VALIDATION_TIMEOUT_MS = 6_000L
@@ -224,7 +231,7 @@ class MainViewModel @Inject constructor(
launch { profileRepository.refresh() }
launch { subscriptionsRepository.refresh() }
launch { libraryRepository.resumePendingWrites() }
- launch { pushRegistrationManager.reconcileRegistration() }
+ launch { reconcileNotificationDelivery() }
launch {
restorePlaybackUnlessExternalRequestArrives()
}
diff --git a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt
index 6675b24a..68100017 100644
--- a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt
+++ b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt
@@ -23,6 +23,8 @@ import dev.typetype.android.data.imports.RemotePortabilityRepository
import dev.typetype.android.data.imports.RoomYoutubeTakeoutImportRepository
import dev.typetype.android.data.library.RoomVideoMetaRepository
import dev.typetype.android.data.notifications.RemoteNotificationsRepository
+import dev.typetype.android.feature.player.PlaybackPrewarm
+import dev.typetype.android.feature.player.PlaybackPrewarmLauncher
import dev.typetype.android.data.network.NetworkAvailabilityObserver
import dev.typetype.android.data.network.PlaybackNetworkMonitor
import dev.typetype.android.data.network.PlaybackNetworkObserver
@@ -179,6 +181,10 @@ abstract class RepositoryModule {
@Singleton
abstract fun bindPushRepository(impl: RemotePushRepository): PushRepository
+ @Binds
+ @Singleton
+ internal abstract fun bindPlaybackPrewarm(impl: PlaybackPrewarmLauncher): PlaybackPrewarm
+
@Binds
@Singleton
internal abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository
diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt
new file mode 100644
index 00000000..ef0efa8d
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt
@@ -0,0 +1,106 @@
+package dev.typetype.android.core.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import coil3.compose.LocalPlatformContext
+import coil3.request.ImageRequest
+import dev.typetype.android.core.ui.share.LocalServerBaseUrl
+import dev.typetype.android.core.ui.share.buildImageUrl
+import kotlinx.coroutines.delay
+
+@Composable
+fun ChannelAvatar(
+ avatarUrl: String,
+ name: String,
+ size: Dp,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+) {
+ val serverBaseUrl = LocalServerBaseUrl.current
+ val context = LocalPlatformContext.current
+ var attempt by remember(avatarUrl) { mutableIntStateOf(0) }
+ var loaded by remember(avatarUrl, attempt) { mutableStateOf(false) }
+ val hasSource = avatarUrl.isNotBlank()
+ var missingExpired by remember(avatarUrl) { mutableStateOf(false) }
+
+ LaunchedEffect(avatarUrl, hasSource) {
+ if (hasSource) {
+ missingExpired = false
+ return@LaunchedEffect
+ }
+ missingExpired = false
+ delay(MISSING_AVATAR_GRACE_MILLIS)
+ missingExpired = true
+ }
+
+ val missing = !hasSource && !missingExpired
+ val loading = missing || (hasSource && !loaded)
+ Box(
+ modifier = modifier
+ .size(size)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .semantics { contentDescription?.let { this.contentDescription = it } },
+ contentAlignment = Alignment.Center,
+ ) {
+ if (!loading) {
+ Text(
+ text = avatarInitial(name),
+ style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (hasSource) {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(avatarRetryUrl(buildImageUrl(serverBaseUrl, avatarUrl), attempt))
+ .build(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ onSuccess = { loaded = true },
+ onError = {
+ if (attempt < MAX_AVATAR_RETRIES) attempt += 1
+ },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ if (loading) {
+ TypeTypeSkeletonCircle(size = size)
+ }
+ }
+}
+
+internal fun avatarRetryUrl(url: String, attempt: Int): String {
+ if (attempt <= 0 || url.isBlank()) return url
+ val separator = if (url.contains('?')) '&' else '?'
+ return "$url$separator$AVATAR_RETRY_PARAM=$attempt"
+}
+
+internal fun avatarInitial(name: String): String =
+ name.trim().firstOrNull()?.uppercase() ?: "?"
+
+private const val AVATAR_RETRY_PARAM = "_tt_avatar_retry"
+private const val MAX_AVATAR_RETRIES = 2
+private const val MISSING_AVATAR_GRACE_MILLIS = 1_500L
diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt
index a0a73a57..9a6fa02d 100644
--- a/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt
+++ b/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt
@@ -85,10 +85,9 @@ fun HorizontalVideoCard(
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
- AsyncImage(
- model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl),
+ SkeletonImage(
+ imageUrl = branding.thumbnailUrl,
contentDescription = null,
- contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
)
VideoThumbnailBadges(
diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt
index 62fa31ca..29baa6fb 100644
--- a/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt
+++ b/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt
@@ -84,10 +84,9 @@ fun PlaylistVideoCard(
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
- AsyncImage(
- model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl),
+ SkeletonImage(
+ imageUrl = branding.thumbnailUrl,
contentDescription = null,
- contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
if (isWatched) {
diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt
index 3d2bacf2..576bd4ac 100644
--- a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt
+++ b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt
@@ -14,10 +14,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -32,15 +32,15 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
import dev.typetype.android.R
import dev.typetype.android.core.ui.branding.rememberVideoBranding
-import dev.typetype.android.core.ui.share.LocalServerBaseUrl
-import dev.typetype.android.core.ui.share.buildImageUrl
import dev.typetype.android.domain.feed.Video
import dev.typetype.android.domain.feed.VideoAvailability
import dev.typetype.android.domain.feed.availabilityAt
import dev.typetype.android.domain.feed.releaseTimeMillis
+import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS
+import dev.typetype.android.feature.player.LocalPlaybackPrewarm
+import kotlinx.coroutines.delay
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -54,7 +54,14 @@ fun RelatedVideoCard(
) {
var menuVisible by remember { mutableStateOf(false) }
var availabilityVisible by remember { mutableStateOf(false) }
- val serverBaseUrl = LocalServerBaseUrl.current
+ val playbackPrewarm = LocalPlaybackPrewarm.current
+
+ LaunchedEffect(video.url, playbackPrewarm) {
+ if (playbackPrewarm == null) return@LaunchedEffect
+ delay(CARD_PREWARM_DELAY_MILLIS)
+ playbackPrewarm.prewarm(video.url, video.isLive)
+ }
+
val availability = video.availabilityAt(System.currentTimeMillis())
val branding = rememberVideoBranding(
sourceUrl = video.url,
@@ -85,10 +92,9 @@ fun RelatedVideoCard(
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
- AsyncImage(
- model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl),
+ SkeletonImage(
+ imageUrl = branding.thumbnailUrl,
contentDescription = null,
- contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
)
if (menuItemState.isWatched) {
@@ -112,18 +118,15 @@ fun RelatedVideoCard(
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
- AsyncImage(
- model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl),
+ ChannelAvatar(
+ avatarUrl = video.uploaderAvatarUrl,
+ name = video.uploaderName,
+ size = 18.dp,
contentDescription = stringResource(
R.string.video_open_channel_accessibility,
video.uploaderName,
),
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .size(18.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.surfaceVariant)
- .combinedClickable(onClick = onChannelClick, role = Role.Button),
+ modifier = Modifier.combinedClickable(onClick = onChannelClick, role = Role.Button),
)
Spacer(Modifier.width(6.dp))
Text(
diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt
new file mode 100644
index 00000000..c3b84609
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt
@@ -0,0 +1,51 @@
+package dev.typetype.android.core.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.layout.ContentScale
+import coil3.compose.AsyncImage
+import coil3.compose.LocalPlatformContext
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import dev.typetype.android.core.ui.share.LocalServerBaseUrl
+import dev.typetype.android.core.ui.share.buildImageUrl
+
+@Composable
+fun SkeletonImage(
+ imageUrl: String,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ shape: Shape = SkeletonCardShape,
+ contentScale: ContentScale = ContentScale.Crop,
+ crossfadeMillis: Int = 0,
+) {
+ val serverBaseUrl = LocalServerBaseUrl.current
+ val context = LocalPlatformContext.current
+ var loaded by remember(imageUrl) { mutableStateOf(false) }
+ val hasSource = imageUrl.isNotBlank()
+ Box(modifier = modifier.clip(shape)) {
+ if (hasSource && !loaded) {
+ TypeTypeSkeleton(modifier = Modifier.matchParentSize(), shape = shape)
+ }
+ if (hasSource) {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(buildImageUrl(serverBaseUrl, imageUrl))
+ .apply { if (crossfadeMillis > 0) crossfade(crossfadeMillis) }
+ .build(),
+ contentDescription = contentDescription,
+ contentScale = contentScale,
+ onSuccess = { loaded = true },
+ onError = { loaded = false },
+ modifier = Modifier.matchParentSize(),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt
new file mode 100644
index 00000000..111ebf69
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt
@@ -0,0 +1,86 @@
+package dev.typetype.android.core.ui.components
+
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Composable
+internal fun skeletonPulseAlpha(): Float {
+ val transition = rememberInfiniteTransition(label = "skeleton")
+ val alpha by transition.animateFloat(
+ initialValue = SKELETON_PULSE_MAX,
+ targetValue = SKELETON_PULSE_MIN,
+ animationSpec = infiniteRepeatable(
+ animation = tween(SKELETON_PULSE_MILLIS),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "skeleton alpha",
+ )
+ return alpha
+}
+
+@Composable
+internal fun TypeTypeSkeleton(
+ modifier: Modifier = Modifier,
+ shape: Shape = RoundedCornerShape(SKELETON_CORNER),
+ alpha: Float = skeletonPulseAlpha(),
+) {
+ Spacer(
+ modifier = modifier
+ .clip(shape)
+ .background(
+ MaterialTheme.colorScheme.onSurface.copy(alpha = SKELETON_TINT * alpha),
+ ),
+ )
+}
+
+@Composable
+internal fun TypeTypeSkeletonLine(
+ widthFraction: Float,
+ height: Dp = 10.dp,
+ modifier: Modifier = Modifier,
+ alpha: Float = skeletonPulseAlpha(),
+) {
+ TypeTypeSkeleton(
+ modifier = modifier.fillMaxWidth(widthFraction).height(height),
+ alpha = alpha,
+ )
+}
+
+@Composable
+internal fun TypeTypeSkeletonCircle(
+ size: Dp,
+ modifier: Modifier = Modifier,
+ alpha: Float = skeletonPulseAlpha(),
+) {
+ TypeTypeSkeleton(
+ modifier = modifier.size(size),
+ shape = CircleShape,
+ alpha = alpha,
+ )
+}
+
+internal val SkeletonCardShape = RoundedCornerShape(12.dp)
+
+private const val SKELETON_CORNER = 4
+private const val SKELETON_TINT = 0.15f
+private const val SKELETON_PULSE_MIN = 0.55f
+private const val SKELETON_PULSE_MAX = 1f
+private const val SKELETON_PULSE_MILLIS = 850
diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt
index d0e300fd..85dc515c 100644
--- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt
+++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt
@@ -15,13 +15,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -39,17 +39,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
-import coil3.compose.AsyncImage
-import coil3.request.ImageRequest
-import coil3.request.crossfade
+import kotlinx.coroutines.delay
import dev.typetype.android.R
import dev.typetype.android.core.ui.branding.rememberVideoBranding
-import dev.typetype.android.core.ui.share.LocalServerBaseUrl
-import dev.typetype.android.core.ui.share.buildImageUrl
import dev.typetype.android.domain.feed.Video
import dev.typetype.android.domain.feed.VideoAvailability
import dev.typetype.android.domain.feed.availabilityAt
import dev.typetype.android.domain.feed.releaseTimeMillis
+import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS
+import dev.typetype.android.feature.player.LocalPlaybackPrewarm
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -63,7 +61,6 @@ fun VideoCard(
) {
var menuVisible by remember { mutableStateOf(false) }
var availabilityVisible by remember { mutableStateOf(false) }
- val serverBaseUrl = LocalServerBaseUrl.current
val availability = video.availabilityAt(System.currentTimeMillis())
val metadata = video.metadataText()
val branding = rememberVideoBranding(
@@ -73,6 +70,13 @@ fun VideoCard(
durationSeconds = video.durationSeconds,
)
val openDescription = stringResource(R.string.video_open_accessibility, branding.title)
+ val playbackPrewarm = LocalPlaybackPrewarm.current
+
+ LaunchedEffect(video.url, playbackPrewarm) {
+ if (playbackPrewarm == null) return@LaunchedEffect
+ delay(CARD_PREWARM_DELAY_MILLIS)
+ playbackPrewarm.prewarm(video.url, video.isLive)
+ }
Column(
modifier = modifier
@@ -93,13 +97,10 @@ fun VideoCard(
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
- AsyncImage(
- model = ImageRequest.Builder(LocalContext.current)
- .data(buildImageUrl(serverBaseUrl, branding.thumbnailUrl))
- .crossfade(200)
- .build(),
+ SkeletonImage(
+ imageUrl = branding.thumbnailUrl,
contentDescription = null,
- contentScale = ContentScale.Crop,
+ crossfadeMillis = 200,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
)
if (menuItemState.isWatched) {
@@ -119,9 +120,6 @@ fun VideoCard(
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.Top) {
val avatarModifier = Modifier
- .size(36.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.surfaceVariant)
.let {
if (onChannelClick != null) {
it.combinedClickable(onClick = onChannelClick, role = Role.Button)
@@ -129,14 +127,15 @@ fun VideoCard(
it
}
}
- AsyncImage(
- model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl),
+ ChannelAvatar(
+ avatarUrl = video.uploaderAvatarUrl,
+ name = video.uploaderName,
+ size = 36.dp,
contentDescription = if (onChannelClick != null) {
stringResource(R.string.video_open_channel_accessibility, video.uploaderName)
} else {
null
},
- contentScale = ContentScale.Crop,
modifier = avatarModifier,
)
Spacer(Modifier.width(10.dp))
diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCardSkeleton.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCardSkeleton.kt
new file mode 100644
index 00000000..9fe547a1
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCardSkeleton.kt
@@ -0,0 +1,98 @@
+package dev.typetype.android.core.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import dev.typetype.android.R
+
+@Composable
+internal fun VideoCardSkeleton() {
+ val alpha = skeletonPulseAlpha()
+ Column(modifier = Modifier.fillMaxWidth()) {
+ TypeTypeSkeleton(
+ modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
+ shape = SkeletonCardShape,
+ alpha = alpha,
+ )
+ Spacer(Modifier.height(10.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ TypeTypeSkeletonCircle(size = 36.dp, alpha = alpha)
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(7.dp),
+ ) {
+ TypeTypeSkeletonLine(widthFraction = 1f, height = 12.dp, alpha = alpha)
+ TypeTypeSkeletonLine(widthFraction = 0.72f, height = 12.dp, alpha = alpha)
+ TypeTypeSkeletonLine(widthFraction = 0.45f, alpha = alpha)
+ }
+ }
+ }
+}
+
+@Composable
+internal fun RelatedVideoCardSkeleton(thumbnailWidth: Dp = 148.dp) {
+ val alpha = skeletonPulseAlpha()
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ TypeTypeSkeleton(
+ modifier = Modifier.width(thumbnailWidth).aspectRatio(16f / 9f),
+ shape = SkeletonCardShape,
+ alpha = alpha,
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ TypeTypeSkeletonLine(widthFraction = 1f, height = 10.dp, alpha = alpha)
+ TypeTypeSkeletonLine(widthFraction = 0.75f, height = 10.dp, alpha = alpha)
+ TypeTypeSkeletonLine(widthFraction = 0.34f, height = 10.dp, alpha = alpha)
+ }
+ }
+}
+
+@Composable
+fun VideoGridSkeleton(
+ modifier: Modifier = Modifier,
+ count: Int = DEFAULT_SKELETON_CARDS,
+ minCellWidth: Dp = 280.dp,
+ horizontalPadding: Dp = 16.dp,
+ verticalPadding: Dp = 16.dp,
+ verticalSpacing: Dp = 24.dp,
+ contentDescription: String? = null,
+) {
+ val description = contentDescription ?: stringResource(R.string.state_loading)
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = minCellWidth),
+ modifier = modifier
+ .fillMaxSize()
+ .semantics { this.contentDescription = description },
+ contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = verticalPadding),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(verticalSpacing),
+ userScrollEnabled = false,
+ ) {
+ items(SKELETON_CARD_SLOTS.take(count), key = { it }) { VideoCardSkeleton() }
+ }
+}
+
+private const val DEFAULT_SKELETON_CARDS = 6
+private val SKELETON_CARD_SLOTS = (0 until 12).toList()
diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt
index 9fbb1c6f..f7aeddb1 100644
--- a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt
+++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt
@@ -1,11 +1,10 @@
package dev.typetype.android.core.ui.share
import androidx.compose.runtime.compositionLocalOf
+import dev.typetype.android.core.url.percentEncode
import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl
import dev.typetype.android.domain.navigation.toPublicWatchParameter
import java.net.URI
-import java.net.URLEncoder
-import java.nio.charset.StandardCharsets
val LocalServerBaseUrl = compositionLocalOf
{ null }
@@ -16,7 +15,7 @@ fun buildShareUrl(serverBaseUrl: String?, videoUrl: String): String {
.removeSuffix("/api")
.trimEnd('/')
if (origin.isBlank()) return videoUrl
- val encoded = URLEncoder.encode(toPublicWatchParameter(videoUrl), StandardCharsets.UTF_8.toString())
+ val encoded = percentEncode(toPublicWatchParameter(videoUrl))
return "$origin/watch?v=$encoded"
}
@@ -77,7 +76,7 @@ fun buildImageUrl(serverBaseUrl: String?, imageUrl: String): String {
if (!source.startsWith("http://") && !source.startsWith("https://")) return source
if (!needsImageProxy(source)) return source
- val encoded = URLEncoder.encode(source, StandardCharsets.UTF_8.toString())
+ val encoded = percentEncode(source)
return "$base/proxy?url=$encoded"
}
diff --git a/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt b/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt
new file mode 100644
index 00000000..0829225e
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt
@@ -0,0 +1,22 @@
+package dev.typetype.android.core.url
+
+private const val HEX = "0123456789ABCDEF"
+
+internal fun percentEncode(value: String): String {
+ val bytes = value.toByteArray(Charsets.UTF_8)
+ val encoded = StringBuilder(bytes.size)
+ for (byte in bytes) {
+ val code = byte.toInt() and 0xff
+ val character = code.toChar()
+ if (character in 'A'..'Z' || character in 'a'..'z' || character in '0'..'9' ||
+ character == '-' || character == '_' || character == '.' || character == '~'
+ ) {
+ encoded.append(character)
+ } else {
+ encoded.append('%')
+ .append(HEX[(code shr 4) and 0x0f])
+ .append(HEX[code and 0x0f])
+ }
+ }
+ return encoded.toString()
+}
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/network/TypeTypeMediaApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt
index b24a71fa..005a315d 100644
--- a/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt
+++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt
@@ -48,6 +48,7 @@ interface TypeTypeMediaApi {
suspend fun createSabrPlayback(
@Path("videoId") videoId: String,
@Body body: SabrPlaybackRequest,
+ @Query("prewarm") prewarm: Boolean? = null,
): Response
@POST("sabr/playback/{sessionId}/seek")
diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt
index 2824192f..77e70599 100644
--- a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt
+++ b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt
@@ -9,6 +9,8 @@ data class PushDeviceRegistrationRequestDto(
val deviceId: String,
val platform: String = "android",
val endpoint: String,
+ val p256dh: String,
+ val auth: String,
val expiresAt: Long? = null,
)
diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt
new file mode 100644
index 00000000..9cbb81a1
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt
@@ -0,0 +1,37 @@
+package dev.typetype.android.data.notifications
+
+import dev.typetype.android.domain.notifications.NotificationItem
+import dev.typetype.android.domain.push.ChannelNotificationsPreference
+import dev.typetype.android.domain.push.matchesChannel
+
+internal data class LocalNotificationDecision(
+ val keysToRecord: List,
+ val notified: List,
+)
+
+internal fun decideLocalNotifications(
+ items: List,
+ knownKeys: Set,
+ seeded: Boolean,
+ enabledChannels: List,
+): LocalNotificationDecision {
+ val fresh = items.filter { item -> item.localNotificationKey() !in knownKeys }
+ if (!seeded) {
+ return LocalNotificationDecision(
+ keysToRecord = items.map { item -> item.localNotificationKey() },
+ notified = emptyList(),
+ )
+ }
+ if (fresh.isEmpty()) return LocalNotificationDecision(emptyList(), emptyList())
+ val deliverableChannels = enabledChannels.filter { it.enabled }
+ val deliverable = fresh
+ .filter { item -> deliverableChannels.any { it.matchesChannel(item.channelUrl) } }
+ .asReversed()
+ return LocalNotificationDecision(
+ keysToRecord = fresh.map { item -> item.localNotificationKey() },
+ notified = deliverable,
+ )
+}
+
+private fun NotificationItem.localNotificationKey(): String =
+ video.id.takeIf { it.isNotBlank() } ?: video.url
diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt
new file mode 100644
index 00000000..5c8c548b
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt
@@ -0,0 +1,55 @@
+package dev.typetype.android.data.notifications
+
+import dev.typetype.android.data.account.AccountDao
+import dev.typetype.android.data.account.AccountScope
+import dev.typetype.android.data.account.ActiveAccountScope
+import dev.typetype.android.domain.notifications.NotificationsRepository
+import dev.typetype.android.domain.push.PushRepository
+import dev.typetype.android.services.push.PushNotifier
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
+
+@Singleton
+class LocalNotificationPoller @Inject constructor(
+ private val activeAccountScope: ActiveAccountScope,
+ private val accountDao: AccountDao,
+ private val notifications: NotificationsRepository,
+ private val pushRepository: PushRepository,
+ private val seenStore: LocalNotificationSeenStore,
+ private val notifier: PushNotifier,
+) {
+ suspend fun poll(): Result = try {
+ Result.success(pollOnce())
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (failure: Throwable) {
+ Result.failure(failure)
+ }
+
+ private suspend fun pollOnce(): Int {
+ val scope = eligibleScope()
+ val enabledChannels = pushRepository.channelPreferences().getOrThrow()
+ val page = notifications.page(0).getOrThrow()
+ val state = seenStore.state(scope)
+ val decision = decideLocalNotifications(
+ items = page.items,
+ knownKeys = state.keys.toHashSet(),
+ seeded = state.seeded,
+ enabledChannels = enabledChannels,
+ )
+ if (decision.keysToRecord.isEmpty() && decision.notified.isEmpty()) return 0
+ seenStore.record(scope, decision.keysToRecord, seeded = true)
+ decision.notified.forEach(notifier::notifySubscription)
+ return decision.notified.size
+ }
+
+ private suspend fun eligibleScope(): AccountScope {
+ val scope = activeAccountScope.require()
+ val account = accountDao.get(scope.serverId, scope.accountId)
+ check(account != null && !account.isGuest) {
+ "Local notifications are unavailable for guest accounts"
+ }
+ return scope
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt
new file mode 100644
index 00000000..c8586d4d
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt
@@ -0,0 +1,37 @@
+package dev.typetype.android.data.notifications
+
+import android.content.Context
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class LocalNotificationScheduler @Inject constructor(
+ @ApplicationContext context: Context,
+) {
+ private val workManager = WorkManager.getInstance(context)
+
+ fun sync() {
+ workManager.enqueueUniquePeriodicWork(
+ WORK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE,
+ PeriodicWorkRequestBuilder(INTERVAL_MINUTES, TimeUnit.MINUTES)
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, MIN_BACKOFF_MINUTES, TimeUnit.MINUTES)
+ .build(),
+ )
+ }
+
+ private companion object {
+ const val WORK_NAME = "typetype-local-notifications"
+ const val INTERVAL_MINUTES = 15L
+ const val MIN_BACKOFF_MINUTES = 5L
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt
new file mode 100644
index 00000000..8dedb537
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt
@@ -0,0 +1,58 @@
+package dev.typetype.android.data.notifications
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import dev.typetype.android.data.account.AccountScope
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.first
+
+@Singleton
+class LocalNotificationSeenStore @Inject constructor(
+ private val dataStore: DataStore,
+) {
+ data class State(
+ val seeded: Boolean,
+ val keys: List,
+ )
+
+ suspend fun state(scope: AccountScope): State = dataStore.data.first().toState(scope)
+
+ suspend fun record(scope: AccountScope, keys: List, seeded: Boolean) {
+ if (keys.isEmpty() && !seeded) return
+ dataStore.edit { preferences ->
+ val merged = (preferences.toState(scope).keys + keys).distinct().takeLast(MAX_KEYS)
+ preferences[keysKey(scope)] = merged.joinToString(SEPARATOR)
+ preferences[seededKey(scope)] = seeded
+ }
+ }
+
+ suspend fun clear(scope: AccountScope) {
+ dataStore.edit { preferences ->
+ preferences.remove(keysKey(scope))
+ preferences.remove(seededKey(scope))
+ }
+ }
+
+ private fun Preferences.toState(scope: AccountScope): State = State(
+ seeded = this[seededKey(scope)] ?: false,
+ keys = this[keysKey(scope)]
+ ?.split(SEPARATOR)
+ ?.filter { it.isNotBlank() }
+ .orEmpty(),
+ )
+
+ private fun keysKey(scope: AccountScope) =
+ stringPreferencesKey("local_notifications_${scope.serverId}_${scope.accountId}_seen")
+
+ private fun seededKey(scope: AccountScope) =
+ booleanPreferencesKey("local_notifications_${scope.serverId}_${scope.accountId}_seeded")
+
+ private companion object {
+ const val SEPARATOR = "\n"
+ const val MAX_KEYS = 300
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt
new file mode 100644
index 00000000..895f7afb
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt
@@ -0,0 +1,27 @@
+package dev.typetype.android.data.notifications
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import dev.typetype.android.services.push.PushNotifier
+
+@HiltWorker
+class LocalNotificationWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted parameters: WorkerParameters,
+ private val poller: LocalNotificationPoller,
+ private val notifier: PushNotifier,
+) : CoroutineWorker(context, parameters) {
+ override suspend fun doWork(): Result {
+ if (!notifier.canNotify()) return Result.success()
+ return poller.poll().fold(
+ onSuccess = { Result.success() },
+ onFailure = { failure ->
+ if (failure is IllegalStateException) Result.success() else Result.retry()
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt
index 46159fc0..fae37d70 100644
--- a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt
+++ b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt
@@ -19,6 +19,8 @@ class PushRegistrationStore @Inject constructor(
data class Registration(
val deviceId: String,
val endpoint: String?,
+ val p256dh: String?,
+ val auth: String?,
)
fun registration(scope: AccountScope): Flow = dataStore.data.map { prefs ->
@@ -39,10 +41,11 @@ class PushRegistrationStore @Inject constructor(
return dataStore.data.first()[deviceIdKey(scope)] ?: error("The stored push device id disappeared")
}
- suspend fun setEndpoint(scope: AccountScope, endpoint: String?) {
+ suspend fun setSubscription(scope: AccountScope, endpoint: String, p256dh: String, auth: String) {
dataStore.edit { prefs ->
- val key = endpointKey(scope)
- if (endpoint == null) prefs.remove(key) else prefs[key] = endpoint
+ prefs[endpointKey(scope)] = endpoint
+ prefs[p256dhKey(scope)] = p256dh
+ prefs[authKey(scope)] = auth
}
}
@@ -50,19 +53,32 @@ class PushRegistrationStore @Inject constructor(
dataStore.edit { prefs ->
prefs.remove(deviceIdKey(scope))
prefs.remove(endpointKey(scope))
+ prefs.remove(p256dhKey(scope))
+ prefs.remove(authKey(scope))
}
}
private fun Preferences.registration(scope: AccountScope): Registration? {
val deviceId = this[deviceIdKey(scope)]?.takeIf(String::isNotBlank) ?: return null
- return Registration(deviceId, this[endpointKey(scope)])
+ return Registration(
+ deviceId = deviceId,
+ endpoint = this[endpointKey(scope)],
+ p256dh = this[p256dhKey(scope)],
+ auth = this[authKey(scope)],
+ )
}
private fun deviceIdKey(scope: AccountScope) =
- stringPreferencesKey("push_device_id_${scopeKey(scope)}")
+ stringPreferencesKey("push_device_id_" + scopeKey(scope))
private fun endpointKey(scope: AccountScope) =
- stringPreferencesKey("push_endpoint_${scopeKey(scope)}")
+ stringPreferencesKey("push_endpoint_" + scopeKey(scope))
+
+ private fun p256dhKey(scope: AccountScope) =
+ stringPreferencesKey("push_p256dh_" + scopeKey(scope))
+
+ private fun authKey(scope: AccountScope) =
+ stringPreferencesKey("push_auth_" + scopeKey(scope))
private fun scopeKey(scope: AccountScope) = "${scope.serverId}_${scope.accountId}"
}
diff --git a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt
index 7c5fe569..19fea4c0 100644
--- a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt
+++ b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt
@@ -36,11 +36,16 @@ class RemotePushRepository @Inject constructor(
?: PushCapability()
}
- override suspend fun registerDevice(deviceId: String, endpoint: String): Result = guarded {
+ override suspend fun registerDevice(
+ deviceId: String,
+ endpoint: String,
+ p256dh: String,
+ auth: String,
+ ): Result = guarded {
val api = apiHolder.require(requireEligibleScope())
val response = withContext(Dispatchers.IO) {
api.registerPushDevice(
- PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint),
+ PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint, p256dh = p256dh, auth = auth),
)
}
response.requireSuccessfulResponse()
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/data/stream/SabrPlaybackRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt
index 0e663e06..21de4b17 100644
--- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt
+++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt
@@ -41,6 +41,13 @@ class SabrPlaybackRepositoryImpl @Inject constructor(
preparer.prepareOnce(api, target.requestScope.baseUrl, target, startTimeMs)
}
+ override suspend fun prewarm(
+ target: SabrPlaybackTarget,
+ startTimeMs: Long,
+ ): Result = execute(target) { api ->
+ preparer.prepare(api, target.requestScope.baseUrl, target, startTimeMs, prewarm = true)
+ }
+
override suspend fun seek(
target: SabrPlaybackTarget,
binding: SabrPlaybackBinding,
diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt
index c7db219d..cb0323a1 100644
--- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt
+++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt
@@ -25,7 +25,8 @@ internal class SabrPlaybackSessionPreparer(
baseUrl: String,
target: SabrPlaybackTarget,
startTimeMs: Long = 0L,
- ): SabrPlaybackSession = createSessionWithRecovery(api, baseUrl, target, startTimeMs)
+ prewarm: Boolean = false,
+ ): SabrPlaybackSession = createSessionWithRecovery(api, baseUrl, target, startTimeMs, prewarm)
suspend fun prepareOnce(
api: TypeTypeMediaApi,
@@ -39,11 +40,13 @@ internal class SabrPlaybackSessionPreparer(
baseUrl: String,
target: SabrPlaybackTarget,
startTimeMs: Long,
+ prewarm: Boolean = false,
): SabrPlaybackSession {
val response = transientPlaybackRequest(pause, network) {
api.createSabrPlayback(
target.videoId,
target.controlRequest(startTimeMs),
+ prewarm.takeIf { it },
)
}
response.requireControlEndpoint(
@@ -139,8 +142,9 @@ internal class SabrPlaybackSessionPreparer(
baseUrl: String,
target: SabrPlaybackTarget,
startTimeMs: Long,
+ prewarm: Boolean = false,
): SabrPlaybackSession = try {
- createSession(api, baseUrl, target, startTimeMs)
+ createSession(api, baseUrl, target, startTimeMs, prewarm)
} catch (failure: SabrPlaybackRecoveryException) {
recoverSession(api, baseUrl, target, startTimeMs, failure)
}
diff --git a/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt b/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt
index ab9d568c..10f052a4 100644
--- a/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt
+++ b/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt
@@ -14,6 +14,11 @@ internal fun resolveServerUrl(baseUrl: String, value: String?): String? {
return resolved.takeIf { it.hasSameOrigin(server) }?.toString()
}
+internal fun resolvePlaybackUrl(baseUrl: String, value: String?): String? {
+ val source = value?.takeIf { it.isNotBlank() } ?: return null
+ return source.toHttpUrlOrNull()?.toString() ?: resolveServerUrl(baseUrl, source)
+}
+
internal fun resolveSabrPlaybackManifestUrl(
baseUrl: String,
value: String?,
diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt
index dc0e55a4..b23c02b1 100644
--- a/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt
+++ b/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt
@@ -26,9 +26,18 @@ internal suspend fun TypeTypeMediaApi.loadYouTubeSabrBootstrapResponse(
): Response {
val response = youtubeSabrBootstrap(videoUrl)
response.rejectSabrRedirect("SABR stream bootstrap attempted to redirect")
- return response
+ if (response.isSuccessful) return response
+ if (!response.canFallBackToSabrStreams()) return response
+ val fallback = youtubeSabrStreams(videoUrl)
+ fallback.rejectSabrRedirect("SABR stream discovery attempted to redirect")
+ return fallback
}
+private fun Response.canFallBackToSabrStreams(): Boolean =
+ code() in BOOTSTRAP_FALLBACK_STATUSES
+
+private val BOOTSTRAP_FALLBACK_STATUSES = setOf(404, 405, 422, 500, 501, 502, 503, 504)
+
private suspend fun TypeTypeMediaApi.fallbackToGeneric(
response: Response,
videoUrl: String,
@@ -40,16 +49,19 @@ private fun Response.canFallbackToGeneric(): Boolean =
internal fun StreamResponse.hasPlayableSabrContract(baseUrl: String? = null): Boolean {
val playableVideoItags = (videoStreams + videoOnlyStreams).filter {
it.deliveryMethod == SABR_DELIVERY_METHOD && it.itag > 0 &&
- it.manifestUrl.isAllowedSabrManifest(baseUrl) && isServerSabrVideoFormat(it.codec)
+ it.manifestUrl.isAllowedServerManifest(baseUrl) && isServerSabrVideoFormat(it.codec)
}.mapTo(mutableSetOf()) { it.itag }
return playableVideoItags.isNotEmpty() && audioStreams.any {
it.deliveryMethod == SABR_DELIVERY_METHOD && it.itag > 0 &&
- it.itag !in playableVideoItags && it.manifestUrl.isAllowedSabrManifest(baseUrl) &&
+ it.itag !in playableVideoItags && it.manifestUrl.isAllowedServerManifest(baseUrl) &&
isServerSabrAudioFormat(it.mimeType, it.codec)
}
}
-private fun String?.isAllowedSabrManifest(baseUrl: String?): Boolean =
+internal fun StreamResponse.hasPlayableLiveContract(baseUrl: String? = null): Boolean =
+ (isLive || hasLiveManifest) && hlsUrl.isAllowedServerManifest(baseUrl)
+
+private fun String?.isAllowedServerManifest(baseUrl: String?): Boolean =
!isNullOrBlank() && (baseUrl == null || resolveServerUrl(baseUrl, this) != null)
internal fun String.streamProvider(): StreamProvider {
diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt
index 7d81fb1c..3ee789cf 100644
--- a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt
+++ b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt
@@ -2,6 +2,7 @@ package dev.typetype.android.data.stream
import dev.typetype.android.data.account.AccountScopeProvider
import dev.typetype.android.core.error.CodedFailure
+import dev.typetype.android.core.url.percentEncode
import dev.typetype.android.data.network.PlaybackNetworkObserver
import dev.typetype.android.data.network.dto.AudioStreamItem
import dev.typetype.android.data.network.dto.PreviewFrameItem
@@ -26,8 +27,6 @@ import dev.typetype.android.domain.stream.StreamVideoSource
import dev.typetype.android.domain.stream.isServerSabrAudioFormat
import dev.typetype.android.domain.stream.isServerSabrVideoFormat
import dev.typetype.android.domain.server.ServerRepository
-import java.net.URLEncoder
-import java.nio.charset.StandardCharsets
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToLong
@@ -103,7 +102,10 @@ internal class StreamRepositoryImpl @Inject constructor(
}
val body = response.body() ?: error("Empty stream body")
activeAccountScope.verify(scope)
- if (provider == StreamProvider.YouTube && !body.hasPlayableSabrContract(server.baseUrl)) {
+ if (provider == StreamProvider.YouTube &&
+ !body.hasPlayableSabrContract(server.baseUrl) &&
+ !body.hasPlayableLiveContract(server.baseUrl)
+ ) {
throw SabrContractException()
}
body.toDomain(videoUrl, server.baseUrl, scope, provider)
@@ -115,7 +117,9 @@ internal class StreamRepositoryImpl @Inject constructor(
scope: dev.typetype.android.data.account.AccountScope,
provider: StreamProvider,
): Stream {
- val serverSabr = provider == StreamProvider.YouTube
+ val serverYoutube = provider == StreamProvider.YouTube
+ val serverSabr = serverYoutube && hasPlayableSabrContract(baseUrl)
+ val resolvedHlsUrl = resolvePlaybackUrl(baseUrl, hlsUrl).orEmpty()
return Stream(
playbackContract = if (serverSabr) {
StreamPlaybackContract.ServerSabr
@@ -136,12 +140,12 @@ internal class StreamRepositoryImpl @Inject constructor(
likeCount = likeCount,
dislikeCount = dislikeCount,
uploadedAtMillis = uploaded,
- hlsUrl = hlsUrl.takeIf { !serverSabr && it.isNotBlank() },
+ hlsUrl = resolvedHlsUrl.takeIf { !serverSabr && it.isNotBlank() },
dashMpdUrl = dashMpdUrl.takeIf { !serverSabr && it.isNotBlank() },
- progressiveUrl = pickBestProgressiveStream(videoStreams).takeUnless { serverSabr },
+ progressiveUrl = pickBestProgressiveStream(baseUrl, videoStreams).takeUnless { serverSabr },
serverDashManifestUrl = serverManifestUrl(baseUrl, "streams/manifest", videoUrl)
- .takeUnless { serverSabr },
- serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && it.isNotBlank() }
+ .takeUnless { serverSabr || serverYoutube },
+ serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && !serverYoutube && it.isNotBlank() }
?.let { serverManifestUrl(baseUrl, "streams/hls-manifest", videoUrl) },
serverSabrManifestUrl = resolveServerUrl(baseUrl, firstSabrManifestUrl()),
sabrVideoStreams = (videoOnlyStreams + videoStreams)
@@ -190,6 +194,7 @@ internal class StreamRepositoryImpl @Inject constructor(
isLive = isLive,
isPostLive = isPostLive,
isLiveContent = isLiveContent,
+ requiresMembership = requiresMembership,
category = category,
)
}
@@ -201,11 +206,14 @@ internal class StreamRepositoryImpl @Inject constructor(
item.manifestUrl.takeIf { item.deliveryMethod == SABR_DELIVERY_METHOD }
}
- private fun pickBestProgressiveStream(videoStreams: List): String? =
+ private fun pickBestProgressiveStream(
+ baseUrl: String,
+ videoStreams: List,
+ ): String? =
videoStreams
.filter { !it.isVideoOnly && it.url.isNotBlank() }
.maxByOrNull { it.height }
- ?.url
+ ?.let { resolvePlaybackUrl(baseUrl, it.url) }
private fun selectStoryboard(storyboards: List): StreamStoryboard? =
storyboards.filter { it.frameWidth >= TARGET_STORYBOARD_FRAME_WIDTH }
@@ -225,21 +233,26 @@ internal class StreamRepositoryImpl @Inject constructor(
private fun serverManifestUrl(baseUrl: String, path: String, videoUrl: String): String {
val normalizedBaseUrl = baseUrl.trimEnd('/')
- val encoded = URLEncoder.encode(videoUrl, StandardCharsets.UTF_8)
+ val encoded = percentEncode(videoUrl)
return "$normalizedBaseUrl/$path?url=$encoded"
}
private fun VideoStreamItem.toDomainVideoSource(baseUrl: String): StreamVideoSource = StreamVideoSource(
- url = if (deliveryMethod == SABR_DELIVERY_METHOD) resolveServerUrl(baseUrl, manifestUrl).orEmpty() else url,
+ url = if (deliveryMethod == SABR_DELIVERY_METHOD) {
+ resolveServerUrl(baseUrl, manifestUrl).orEmpty()
+ } else {
+ resolvePlaybackUrl(baseUrl, url).orEmpty()
+ },
mimeType = mimeType,
+ playbackMimeType = HLS_MIME_TYPE.takeIf { deliveryMethod == HLS_DELIVERY_METHOD },
codec = codec,
resolution = buildString {
- append("${height.coerceAtLeast(0)}p")
+ append("${resolvedHeight()}p")
fps.takeIf { it > 0 }?.let { append(it) }
if (resolution.contains("HDR", ignoreCase = true)) append(" HDR")
},
- width = width,
- height = height,
+ width = width.coerceAtLeast(0),
+ height = resolvedHeight(),
fps = fps,
bitrate = bitrate,
isVideoOnly = isVideoOnly,
@@ -247,7 +260,11 @@ internal class StreamRepositoryImpl @Inject constructor(
)
private fun AudioStreamItem.toDomainAudioSource(baseUrl: String): StreamAudioSource = StreamAudioSource(
- url = if (deliveryMethod == SABR_DELIVERY_METHOD) resolveServerUrl(baseUrl, manifestUrl).orEmpty() else url,
+ url = if (deliveryMethod == SABR_DELIVERY_METHOD) {
+ resolveServerUrl(baseUrl, manifestUrl).orEmpty()
+ } else {
+ resolvePlaybackUrl(baseUrl, url).orEmpty()
+ },
mimeType = mimeType,
codec = codec,
bitrate = bitrate,
@@ -294,8 +311,15 @@ internal suspend fun cancellableStreamResult(
}
private const val SABR_DELIVERY_METHOD = "sabr"
+private const val HLS_DELIVERY_METHOD = "hls"
+private const val HLS_MIME_TYPE = "application/vnd.apple.mpegurl"
private const val TARGET_STORYBOARD_FRAME_WIDTH = 160
+private fun VideoStreamItem.resolvedHeight(): Int =
+ height.takeIf { it > 0 } ?: RESOLUTION_HEIGHT_PATTERN.find(resolution)?.groupValues?.get(1)?.toIntOrNull() ?: 0
+
+private val RESOLUTION_HEIGHT_PATTERN = Regex("(\\d{3,4})")
+
private class SabrContractException :
IllegalStateException("The server returned no playable SABR contract"),
CodedFailure {
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/push/PushModels.kt b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt
index 08befe8e..a2791d45 100644
--- a/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt
+++ b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt
@@ -10,6 +10,11 @@ data class ChannelNotificationsPreference(
val enabled: Boolean,
)
+fun ChannelNotificationsPreference.matchesChannel(channelUrl: String): Boolean =
+ this.channelUrl == channelUrl ||
+ channelUrl.startsWith("${this.channelUrl}/") ||
+ this.channelUrl.startsWith("$channelUrl/")
+
sealed interface PushRegistrationStatus {
data object Disabled : PushRegistrationStatus
diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt
index 7579a30a..d8fcb3f5 100644
--- a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt
+++ b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt
@@ -5,7 +5,7 @@ import dev.typetype.android.domain.server.PushCapability
interface PushRepository {
suspend fun currentCapability(): PushCapability
- suspend fun registerDevice(deviceId: String, endpoint: String): Result
+ suspend fun registerDevice(deviceId: String, endpoint: String, p256dh: String, auth: String): Result
suspend fun unregisterDevice(deviceId: String): Result
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/domain/stream/SabrPlaybackRepository.kt b/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt
index 18a9a52b..26725d7f 100644
--- a/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt
+++ b/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt
@@ -49,6 +49,9 @@ interface SabrPlaybackRepository {
onFailure = Result.Companion::failure,
)
+ suspend fun prewarm(target: SabrPlaybackTarget, startTimeMs: Long = 0L): Result =
+ prepare(target, startTimeMs)
+
suspend fun recoverOnce(
target: SabrPlaybackTarget,
startTimeMs: Long,
diff --git a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt
index fa142f66..35acd661 100644
--- a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt
+++ b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt
@@ -41,6 +41,7 @@ data class Stream(
val isLive: Boolean = false,
val isPostLive: Boolean = false,
val isLiveContent: Boolean = false,
+ val requiresMembership: Boolean = false,
val category: String? = null,
)
@@ -96,6 +97,7 @@ enum class StreamPlaybackContract {
data class StreamVideoSource(
val url: String,
val mimeType: String,
+ val playbackMimeType: String? = null,
val codec: String?,
val resolution: String,
val width: Int,
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..a9b84ab9 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
@@ -1,8 +1,8 @@
package dev.typetype.android.feature.channel
import dev.typetype.android.data.account.ActiveAccountScope
-import dev.typetype.android.domain.push.ChannelNotificationsPreference
import dev.typetype.android.domain.push.PushRepository
+import dev.typetype.android.domain.push.matchesChannel
import dev.typetype.android.domain.server.ServerRepository
import javax.inject.Inject
@@ -14,21 +14,18 @@ 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? =
pushRepository.channelPreferences().getOrNull()
- ?.firstOrNull { it.matches(channelUrl) }
+ ?.firstOrNull { it.matchesChannel(channelUrl) }
?.enabled
suspend fun setEnabled(channelUrl: String, enabled: Boolean): Boolean? {
pushRepository.setChannelPreference(channelUrl, enabled).getOrNull() ?: return null
return enabled
}
-
- private fun ChannelNotificationsPreference.matches(channelUrl: String): Boolean =
- this.channelUrl == channelUrl ||
- channelUrl.endsWith(this.channelUrl) ||
- this.channelUrl.endsWith(channelUrl)
}
diff --git a/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt b/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt
index 74df4e9c..72a96ace 100644
--- a/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt
+++ b/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt
@@ -36,6 +36,7 @@ import dev.typetype.android.R
import dev.typetype.android.core.ui.branding.rememberVideoBranding
import dev.typetype.android.core.ui.components.SectionHeader
import dev.typetype.android.core.ui.components.VideoDurationBadge
+import dev.typetype.android.core.ui.components.SkeletonImage
import dev.typetype.android.core.ui.share.LocalServerBaseUrl
import dev.typetype.android.core.ui.share.buildImageUrl
import dev.typetype.android.domain.library.HistoryItem
@@ -98,10 +99,9 @@ private fun ContinueWatchingCard(
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
- AsyncImage(
- model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl),
+ SkeletonImage(
+ imageUrl = branding.thumbnailUrl,
contentDescription = null,
- contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
)
VideoDurationBadge(
diff --git a/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt b/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt
index fbacd11a..cb07101b 100644
--- a/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt
+++ b/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt
@@ -22,10 +22,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.typetype.android.R
import dev.typetype.android.core.ui.components.AnimatedError
-import dev.typetype.android.core.ui.components.FullScreenLoader
+import dev.typetype.android.core.ui.components.VideoGridSkeleton
import dev.typetype.android.core.ui.components.LazyPaginationFooter
import dev.typetype.android.core.ui.components.SectionHeader
import dev.typetype.android.core.ui.components.VideoCard
+import dev.typetype.android.core.ui.components.VideoCardSkeleton
import dev.typetype.android.feature.menu.VideoMenuScope
import dev.typetype.android.feature.menu.rememberVideoMenuScope
@@ -77,7 +78,7 @@ internal fun HomeContent(
val continueWatching = if (state.hideContinueWatching) emptyList() else state.continueWatching
val showRecommendations = !state.hideHomeRecommendations
when {
- state.isLoading && state.videos.isEmpty() && continueWatching.isEmpty() -> FullScreenLoader()
+ state.isLoading && state.videos.isEmpty() && continueWatching.isEmpty() -> VideoGridSkeleton()
state.errorMessage != null && state.videos.isEmpty() && continueWatching.isEmpty() -> AnimatedError(
message = state.errorMessage,
requestId = state.errorRequestId,
@@ -100,7 +101,7 @@ internal fun HomeContent(
)
}
}
- if (state.isLoading && showRecommendations) {
+ if (state.isLoading && showRecommendations && visibleVideos.isNotEmpty()) {
item(key = "home-refresh", span = { GridItemSpan(maxLineSpan) }) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
@@ -117,6 +118,11 @@ internal fun HomeContent(
modifier = Modifier.padding(horizontal = 4.dp),
)
}
+ if (state.isLoading && visibleVideos.isEmpty()) {
+ items(HOME_SKELETON_SLOTS, key = { "home-skeleton-$it" }) {
+ VideoCardSkeleton()
+ }
+ }
items(
visibleVideos,
key = { "home-${it.id}" },
@@ -156,3 +162,5 @@ private fun HomeEmptyState() {
)
}
}
+
+private val HOME_SKELETON_SLOTS = (0 until 6).toList()
diff --git a/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt b/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt
index 2d3dcb3c..e07f93b9 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt
@@ -1,8 +1,10 @@
package dev.typetype.android.feature.player
-import java.net.URI
+import dev.typetype.android.data.stream.StreamProvider
+import dev.typetype.android.data.stream.streamProvider
-internal fun supportsServerBulletComments(videoUrl: String): Boolean {
- val host = runCatching { URI(videoUrl).host?.lowercase() }.getOrNull() ?: return false
- return host == "nicovideo.jp" || host.endsWith(".nicovideo.jp") || host == "nico.ms"
-}
+internal fun supportsServerBulletComments(videoUrl: String): Boolean =
+ when (videoUrl.streamProvider()) {
+ StreamProvider.NicoNico, StreamProvider.BiliBili -> true
+ StreamProvider.YouTube, StreamProvider.Generic -> false
+ }
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/LocalPlaybackPrewarm.kt b/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt
new file mode 100644
index 00000000..9d648da9
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt
@@ -0,0 +1,11 @@
+package dev.typetype.android.feature.player
+
+import androidx.compose.runtime.staticCompositionLocalOf
+
+fun interface PlaybackPrewarm {
+ fun prewarm(videoUrl: String, knownLive: Boolean)
+}
+
+val LocalPlaybackPrewarm = staticCompositionLocalOf { null }
+
+const val CARD_PREWARM_DELAY_MILLIS = 200L
diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt
similarity index 57%
rename from app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt
rename to app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt
index df0f745a..d980fa3f 100644
--- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt
@@ -1,32 +1,41 @@
-package dev.typetype.android.feature.shorts
+package dev.typetype.android.feature.player
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
import dev.typetype.android.domain.stream.SabrPlaybackRepository
+import dev.typetype.android.domain.stream.Stream
import dev.typetype.android.domain.stream.StreamRepository
import dev.typetype.android.domain.stream.sabrPlaybackTarget
import dev.typetype.android.domain.usersettings.UserSettings
-import dev.typetype.android.feature.player.PlaybackCodecSupport
-import dev.typetype.android.feature.player.RECOMMENDED_CODEC_KEY
-import dev.typetype.android.feature.player.effectiveQuality
-import dev.typetype.android.feature.player.initialAudioKey
-import dev.typetype.android.feature.player.initialQuality
-import dev.typetype.android.feature.player.SabrPlaybackPreloadStore
-import dev.typetype.android.feature.player.sabrSelection
+import dev.typetype.android.domain.usersettings.UserSettingsRepository
import javax.inject.Inject
+import javax.inject.Singleton
-class ShortsPlaybackPreheater @Inject constructor(
+@Singleton
+class PlaybackPreheater @Inject constructor(
+ @ApplicationContext context: Context,
private val streams: StreamRepository,
private val sabr: SabrPlaybackRepository,
private val preloads: SabrPlaybackPreloadStore,
+ private val userSettingsRepository: UserSettingsRepository,
) {
+ private val codecSupport: PlaybackCodecSupport = DevicePlaybackCodecSupport(context)
+
+ suspend fun preheat(videoUrl: String, knownLive: Boolean = false) {
+ if (knownLive) return
+ val settings = userSettingsRepository.current().getOrNull() ?: return
+ preheat(videoUrl, settings, codecSupport, { true })
+ }
+
internal suspend fun preheat(
videoUrl: String,
settings: UserSettings,
codecSupport: PlaybackCodecSupport,
- prepareSession: () -> Boolean,
+ canPrepare: () -> Boolean,
) {
val stream = streams.prefetchPlaybackStream(videoUrl).getOrNull() ?: return
- if (!prepareSession()) return
- if (stream.isLive || stream.isLiveContent) return
+ if (!stream.isPreheatable()) return
+ if (!canPrepare()) return
val selection = stream.sabrSelection(
selectedQuality = stream.initialQuality().effectiveQuality(settings.defaultQuality),
selectedAudioKey = stream.initialAudioKey(
@@ -40,6 +49,9 @@ class ShortsPlaybackPreheater @Inject constructor(
) ?: return
val target = stream.sabrPlaybackTarget(selection)
val reservation = preloads.reserve(target)
- if (reservation.owner) reservation.result.complete(sabr.prepare(target, 0L))
+ if (reservation.owner) reservation.result.complete(sabr.prewarm(target, 0L))
}
}
+
+private fun Stream.isPreheatable(): Boolean =
+ !isLive && !isPostLive && !isLiveContent && !requiresMembership
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt
new file mode 100644
index 00000000..ce7be716
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt
@@ -0,0 +1,31 @@
+package dev.typetype.android.feature.player
+
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+@Singleton
+internal class PlaybackPrewarmLauncher @Inject constructor(
+ private val preheater: PlaybackPreheater,
+) : PlaybackPrewarm {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private var inFlight = false
+
+ override fun prewarm(videoUrl: String, knownLive: Boolean) {
+ if (videoUrl.isBlank()) return
+ synchronized(this) {
+ if (inFlight) return
+ inFlight = true
+ }
+ scope.launch {
+ try {
+ preheater.preheat(videoUrl, knownLive)
+ } finally {
+ synchronized(this@PlaybackPrewarmLauncher) { inFlight = false }
+ }
+ }
+ }
+}
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/PlayerErrorState.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt
index 37998601..95088f13 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt
@@ -33,6 +33,7 @@ fun ErrorState(
-> R.raw.member_only
StreamErrorKind.Generic,
StreamErrorKind.AuthenticationExpired,
+ StreamErrorKind.ContentUnavailable,
StreamErrorKind.LiveUnsupported,
StreamErrorKind.NetworkUnavailable,
StreamErrorKind.SabrInvalidIndex,
@@ -45,6 +46,7 @@ fun ErrorState(
-> R.raw.error_cat
}
val displayMessage = when (classification.kind) {
+ StreamErrorKind.ContentUnavailable -> stringResource(R.string.error_content_unavailable)
StreamErrorKind.MemberOnly -> stringResource(R.string.state_member_only_message)
StreamErrorKind.PaidContent -> stringResource(R.string.video_paid_message)
StreamErrorKind.ScheduledPremiere -> stringResource(R.string.video_scheduled_message)
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/PlayerLoadingState.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerLoadingState.kt
index 8c37e2cf..4b32cce2 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/PlayerLoadingState.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerLoadingState.kt
@@ -12,18 +12,20 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
+import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.typetype.android.core.ui.components.AnimatedLoader
+import dev.typetype.android.feature.player.components.PlayerDetailsSkeleton
@Composable
fun LoadingState() {
Column(
modifier = Modifier
.fillMaxSize()
- .background(Color.Black)
+ .background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
) {
Box(
@@ -35,5 +37,6 @@ fun LoadingState() {
) {
AnimatedLoader(size = 88.dp)
}
+ PlayerDetailsSkeleton()
}
}
diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt
index 0f7a2eac..b97be1fd 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt
@@ -10,6 +10,7 @@ import dev.typetype.android.domain.stream.Stream
import dev.typetype.android.domain.stream.StreamAudioSource
import dev.typetype.android.domain.stream.StreamPlaybackContract
import dev.typetype.android.domain.stream.StreamSubtitleSource
+import dev.typetype.android.domain.stream.StreamVideoSource
import dev.typetype.android.services.MergedStreamMediaKeys
import dev.typetype.android.services.sabrPlaybackBinding
import dev.typetype.android.services.sabrPlaybackTarget
@@ -216,7 +217,6 @@ private fun pickMergedSource(
codecSupport: PlaybackCodecSupport,
selectedCodec: String,
): PlayableSource? {
- if (selectedAudioKey == null) return null
val video = stream.videoOnlyStreams.pickVideo(
selectedQuality,
codecSupport,
@@ -232,7 +232,7 @@ private fun pickMergedSource(
) ?: return null
return PlayableSource(
url = video.url,
- mimeType = video.mimeType.normalizedMimeType(),
+ mimeType = video.playbackContainerMimeType(),
audioUrl = audio.url,
audioMimeType = audio.mimeType.normalizedMimeType(),
)
@@ -247,7 +247,7 @@ private fun pickMuxedSource(
stream.muxedVideoStreams.pickVideo(defaultQuality, codecSupport, selectedCodec)?.let { source ->
PlayableSource(
url = source.url,
- mimeType = source.mimeType.normalizedMimeType() ?: MimeTypes.VIDEO_MP4,
+ mimeType = source.playbackContainerMimeType() ?: MimeTypes.VIDEO_MP4,
)
}
@@ -279,6 +279,9 @@ private fun applyTrackSelectionDefaults(
private fun String.normalizedMimeType(): String? =
substringBefore(";").trim().takeIf { it.isNotBlank() }
+internal fun StreamVideoSource.playbackContainerMimeType(): String? =
+ (playbackMimeType ?: mimeType).normalizedMimeType()
+
internal val StreamAudioSource.key: String
get() = audioTrackId?.takeIf { it.isNotBlank() }
?: audioLocale?.takeIf { it.isNotBlank() }
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/ProviderPlaybackSource.kt b/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt
index 5e452b4f..5fab22af 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt
@@ -17,7 +17,7 @@ internal fun Stream.pickExplicitProviderSource(
if (!video.isVideoOnly) {
return PlayableSource(
url = video.url,
- mimeType = video.mimeType.normalizedProviderMimeType() ?: MimeTypes.VIDEO_MP4,
+ mimeType = video.playbackContainerMimeType() ?: MimeTypes.VIDEO_MP4,
sourceKey = video.videoSelectionKey(),
)
}
@@ -31,7 +31,7 @@ internal fun Stream.pickExplicitProviderSource(
) ?: return null
return PlayableSource(
url = video.url,
- mimeType = video.mimeType.normalizedProviderMimeType(),
+ mimeType = video.playbackContainerMimeType(),
audioUrl = audio.url,
audioMimeType = audio.mimeType.normalizedProviderMimeType(),
sourceKey = video.videoSelectionKey(),
diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/CommentSkeleton.kt b/app/src/main/java/dev/typetype/android/feature/player/components/CommentSkeleton.kt
index 4ffeb1f8..33e025b9 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/components/CommentSkeleton.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/components/CommentSkeleton.kt
@@ -1,11 +1,5 @@
package dev.typetype.android.feature.player.components
-import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -13,47 +7,27 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import dev.typetype.android.core.ui.components.TypeTypeSkeletonCircle
+import dev.typetype.android.core.ui.components.TypeTypeSkeletonLine
+import dev.typetype.android.core.ui.components.skeletonPulseAlpha
@Composable
internal fun CommentSkeleton(avatarSize: Dp = 36.dp) {
- val transition = rememberInfiniteTransition(label = "comment skeleton")
- val alpha by transition.animateFloat(
- initialValue = 0.38f,
- targetValue = 0.72f,
- animationSpec = infiniteRepeatable(
- animation = tween(850),
- repeatMode = RepeatMode.Reverse,
- ),
- label = "comment skeleton alpha",
- )
- val color = MaterialTheme.colorScheme.surfaceVariant
- Row(modifier = Modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }) {
- Spacer(Modifier.size(avatarSize).background(color, CircleShape))
+ val alpha = skeletonPulseAlpha()
+ Row(modifier = Modifier.fillMaxWidth()) {
+ TypeTypeSkeletonCircle(size = avatarSize, alpha = alpha)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
- Spacer(
- Modifier.fillMaxWidth(0.38f).height(12.dp)
- .background(color, RoundedCornerShape(4.dp)),
- )
+ TypeTypeSkeletonLine(widthFraction = 0.38f, height = 12.dp, alpha = alpha)
Spacer(Modifier.height(10.dp))
- Spacer(
- Modifier.fillMaxWidth().height(10.dp)
- .background(color, RoundedCornerShape(4.dp)),
- )
+ TypeTypeSkeletonLine(widthFraction = 1f, alpha = alpha)
Spacer(Modifier.height(6.dp))
- Spacer(
- Modifier.fillMaxWidth(0.7f).height(10.dp)
- .background(color, RoundedCornerShape(4.dp)),
- )
+ TypeTypeSkeletonLine(widthFraction = 0.7f, alpha = alpha)
}
}
}
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/PlayerDetailsSkeleton.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDetailsSkeleton.kt
new file mode 100644
index 00000000..e9ac3a17
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDetailsSkeleton.kt
@@ -0,0 +1,88 @@
+package dev.typetype.android.feature.player.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.typetype.android.core.ui.components.TypeTypeSkeleton
+import dev.typetype.android.core.ui.components.TypeTypeSkeletonCircle
+import dev.typetype.android.core.ui.components.TypeTypeSkeletonLine
+import dev.typetype.android.core.ui.components.skeletonPulseAlpha
+
+@Composable
+internal fun PlayerDetailsSkeleton(modifier: Modifier = Modifier) {
+ val alpha = skeletonPulseAlpha()
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ TitleSkeleton(alpha)
+ UploaderSkeleton(alpha)
+ ActionRowSkeleton(alpha)
+ DescriptionSkeleton(alpha)
+ }
+}
+
+@Composable
+private fun TitleSkeleton(alpha: Float) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ TypeTypeSkeletonLine(widthFraction = 1f, height = 18.dp, alpha = alpha)
+ TypeTypeSkeletonLine(widthFraction = 0.72f, height = 18.dp, alpha = alpha)
+ }
+}
+
+@Composable
+private fun UploaderSkeleton(alpha: Float) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ TypeTypeSkeletonCircle(size = 40.dp, alpha = alpha)
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(7.dp),
+ ) {
+ TypeTypeSkeletonLine(widthFraction = 0.42f, height = 12.dp, alpha = alpha)
+ TypeTypeSkeletonLine(widthFraction = 0.28f, alpha = alpha)
+ }
+ TypeTypeSkeleton(
+ modifier = Modifier.width(104.dp).height(40.dp),
+ alpha = alpha,
+ )
+ }
+}
+
+@Composable
+private fun ActionRowSkeleton(alpha: Float) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ repeat(PLAYER_ACTION_PLACEHOLDERS) {
+ TypeTypeSkeletonCircle(size = 48.dp, alpha = alpha)
+ }
+ }
+}
+
+@Composable
+private fun DescriptionSkeleton(alpha: Float) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ TypeTypeSkeletonLine(widthFraction = 1f, alpha = alpha)
+ TypeTypeSkeletonLine(widthFraction = 0.86f, alpha = alpha)
+ TypeTypeSkeletonLine(widthFraction = 0.52f, alpha = alpha)
+ }
+}
+
+private const val PLAYER_ACTION_PLACEHOLDERS = 6
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/components/UploaderCard.kt b/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt
index 13e00664..7621bdbd 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
@@ -23,13 +22,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
import dev.typetype.android.R
+import dev.typetype.android.core.ui.components.ChannelAvatar
import dev.typetype.android.core.ui.share.LocalServerBaseUrl
import dev.typetype.android.core.ui.share.buildImageUrl
@@ -50,14 +48,10 @@ fun UploaderCard(
modifier = modifier.fillMaxWidth().clickable(onClick = onCardClick),
verticalAlignment = Alignment.CenterVertically,
) {
- AsyncImage(
- model = buildImageUrl(serverBaseUrl, avatarUrl),
- contentDescription = null,
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .size(40.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.surfaceVariant),
+ ChannelAvatar(
+ avatarUrl = avatarUrl,
+ name = name,
+ size = 40.dp,
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
diff --git a/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt b/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt
index 6c8fbbaa..51fe6828 100644
--- a/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt
+++ b/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt
@@ -20,6 +20,7 @@ private val SCHEDULED_PREMIERE_NEEDLES = listOf(
"premieres in",
"premiere has not started",
"premiere scheduled",
+ "live event will begin",
)
private val PAID_CONTENT_NEEDLES = listOf(
@@ -107,6 +108,7 @@ fun parseGeoRestriction(message: String?): String? {
enum class StreamErrorKind {
Generic,
AuthenticationExpired,
+ ContentUnavailable,
MemberOnly,
PaidContent,
ScheduledPremiere,
@@ -168,10 +170,14 @@ fun classifyStreamError(failure: Throwable): StreamErrorClass {
if (code == "members_only") {
return StreamErrorClass(StreamErrorKind.MemberOnly, rawMessage = null, requestId = requestId)
}
+
+ if (code in CONTENT_UNAVAILABLE_CODES) {
+ return StreamErrorClass(StreamErrorKind.ContentUnavailable, rawMessage = null, requestId = requestId)
+ }
if (code == "paid_content") {
return StreamErrorClass(StreamErrorKind.PaidContent, rawMessage = null, requestId = requestId)
}
- if (code == "scheduled_premiere") {
+ if (code == "scheduled_premiere" || code == "live_not_started") {
return StreamErrorClass(StreamErrorKind.ScheduledPremiere, rawMessage = null, requestId = requestId)
}
if (code == "youtube_session_needs_reconnect" || code == "youtube_session_unavailable") {
@@ -268,6 +274,11 @@ private val SABR_UNAVAILABLE_CODES = setOf(
"youtube_sabr_preparation_failed",
)
+private val CONTENT_UNAVAILABLE_CODES = setOf(
+ "content_unavailable",
+ "provider_access_blocked",
+)
+
private val SABR_CONTRACT_CODES = setOf(
"youtube_sabr_contract_mismatch",
"youtube_android_playback_incompatible",
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/search/SearchScreen.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt
index 08ce4aa5..a5ff89f5 100644
--- a/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt
+++ b/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt
@@ -26,7 +26,6 @@ import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.NorthWest
import androidx.compose.material.icons.filled.Search
-import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -52,6 +51,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.typetype.android.R
import dev.typetype.android.core.ui.components.AnimatedError
+import dev.typetype.android.core.ui.components.VideoGridSkeleton
@Composable
fun SearchRoute(
@@ -125,16 +125,7 @@ fun SearchScreen(
}
when {
- state.isLoading -> Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center,
- ) {
- val description = stringResource(R.string.state_loading)
- CircularProgressIndicator(
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.semantics { contentDescription = description },
- )
- }
+ state.isLoading -> VideoGridSkeleton(verticalPadding = 12.dp)
state.errorMessage != null -> AnimatedError(
message = state.errorMessage,
requestId = state.errorRequestId,
diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt
index 323ad25a..883af2bc 100644
--- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt
+++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt
@@ -92,6 +92,12 @@ internal fun PushNotificationsScreen(
if (state.status == PushRegistrationStatus.MissingDistributor) {
DistributorRow()
}
+ Text(
+ text = stringResource(R.string.push_local_fallback),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(20.dp, 4.dp),
+ )
if (state.capabilityEnabled && state.deviceCount > 0) {
StatusRow(
label = stringResource(R.string.push_devices_count, state.deviceCount, state.maxDevices),
diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt
index abd2f67a..fd691660 100644
--- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt
+++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt
@@ -3,6 +3,7 @@ package dev.typetype.android.feature.settings.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+import dev.typetype.android.data.notifications.LocalNotificationScheduler
import dev.typetype.android.domain.push.PushRegistrationStatus
import dev.typetype.android.domain.push.PushRepository
import dev.typetype.android.services.push.PushRegistrationManager
@@ -32,6 +33,7 @@ data class PushSettingsState(
class PushNotificationsViewModel @Inject constructor(
private val pushRepository: PushRepository,
private val registrationManager: PushRegistrationManager,
+ private val localNotificationScheduler: LocalNotificationScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(PushSettingsState())
val state = _state.asStateFlow()
@@ -44,6 +46,7 @@ class PushNotificationsViewModel @Inject constructor(
viewModelScope.launch {
registrationManager.status.collect { status ->
_state.update { it.copy(status = status) }
+ localNotificationScheduler.sync()
}
}
}
@@ -92,4 +95,5 @@ class PushNotificationsViewModel @Inject constructor(
}
}
}
+
}
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/feature/shorts/ShortsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt
index f1f17c70..a79ec3b5 100644
--- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt
+++ b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt
@@ -14,6 +14,7 @@ import dev.typetype.android.domain.library.cacheVideos
import dev.typetype.android.domain.usersettings.UserSettingsRepository
import dev.typetype.android.domain.usersettings.UserSettings
import dev.typetype.android.feature.player.PlaybackCodecSupport
+import dev.typetype.android.feature.player.PlaybackPreheater
import javax.inject.Inject
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
@@ -32,7 +33,7 @@ class ShortsViewModel @Inject constructor(
private val videoMetaRepository: VideoMetaRepository,
private val userSettingsRepository: UserSettingsRepository,
private val errorMapper: UserErrorMapper,
- private val playbackPreheater: ShortsPlaybackPreheater,
+ private val playbackPreheater: PlaybackPreheater,
) : ViewModel() {
private val _state = MutableStateFlow(ShortsState())
val state = _state.asStateFlow()
diff --git a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt
index 61dd74cb..aad65d75 100644
--- a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt
+++ b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt
@@ -1,48 +1,22 @@
package dev.typetype.android.feature.subscriptions
-import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import dev.typetype.android.R
+import dev.typetype.android.core.ui.components.VideoCardSkeleton
@Composable
internal fun SubscriptionsLoadingGrid() {
- val transition = rememberInfiniteTransition(label = "subscriptions-loading")
- val alpha by transition.animateFloat(
- initialValue = 0.45f,
- targetValue = 0.85f,
- animationSpec = infiniteRepeatable(tween(750), RepeatMode.Reverse),
- label = "subscriptions-loading-alpha",
- )
val description = stringResource(R.string.subscriptions_preparing)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 320.dp),
@@ -52,31 +26,6 @@ internal fun SubscriptionsLoadingGrid() {
verticalArrangement = Arrangement.spacedBy(24.dp),
userScrollEnabled = false,
) {
- items((0 until 6).toList()) {
- LoadingCard(alpha)
- }
- }
-}
-
-@Composable
-private fun LoadingCard(alpha: Float) {
- val color = MaterialTheme.colorScheme.surfaceVariant
- Column(modifier = Modifier.fillMaxWidth().alpha(alpha)) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .aspectRatio(16f / 9f)
- .clip(MaterialTheme.shapes.medium)
- .background(color),
- )
- Spacer(Modifier.height(10.dp))
- Row {
- Box(Modifier.size(36.dp).clip(CircleShape).background(color))
- Spacer(Modifier.width(10.dp))
- Column(verticalArrangement = Arrangement.spacedBy(7.dp), modifier = Modifier.weight(1f)) {
- Box(Modifier.fillMaxWidth(0.88f).height(14.dp).clip(CircleShape).background(color))
- Box(Modifier.fillMaxWidth(0.55f).height(10.dp).clip(CircleShape).background(color))
- }
- }
+ items((0 until 6).toList(), key = { it }) { VideoCardSkeleton() }
}
}
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/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt
index d78f6dd9..7a73ac36 100644
--- a/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt
+++ b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt
@@ -42,7 +42,7 @@ class PlaybackQueueItemResolver @Inject constructor(
preferOriginalLanguage = settings.preferOriginalLanguage,
codecSupport = codecSupport,
prepareSabrPlayback = { loaded, selection, _ ->
- sabrPlaybackRepository.prepare(loaded.sabrPlaybackTarget(selection)).getOrThrow()
+ sabrPlaybackRepository.prewarm(loaded.sabrPlaybackTarget(selection)).getOrThrow()
},
),
) { "No playable source for queued video" }
diff --git a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt
index 7fef2ea0..ef8bf960 100644
--- a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt
+++ b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.typetype.android.R
import dev.typetype.android.domain.navigation.toPublicWatchParameter
+import dev.typetype.android.domain.notifications.NotificationItem
import dev.typetype.android.domain.push.PushPayload
import dev.typetype.android.domain.push.parsePushPayload
import javax.inject.Inject
@@ -23,34 +24,67 @@ import javax.inject.Singleton
class PushNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) {
+ fun canNotify(): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled()
+
fun notify(payload: PushPayload) {
+ show(
+ title = payload.channelName.ifBlank { payload.serviceName },
+ text = payload.title,
+ videoUrl = payload.videoUrl,
+ notificationId = subscriptionNotificationId(
+ videoId = payload.videoId,
+ videoUrl = payload.videoUrl,
+ fallback = payload.eventId,
+ ),
+ )
+ }
+
+ fun notifySubscription(item: NotificationItem) {
+ show(
+ title = item.channelName.ifBlank { item.video.uploaderName },
+ text = item.title,
+ videoUrl = item.video.url,
+ notificationId = subscriptionNotificationId(
+ videoId = item.video.id,
+ videoUrl = item.video.url,
+ fallback = item.video.url,
+ ),
+ )
+ }
+
+ private fun show(title: String, text: String, videoUrl: String, notificationId: Int) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createNotificationChannel(notificationChannel())
}
- manager.notify(payload.notificationId(), buildNotification(payload))
+ manager.notify(notificationId, buildNotification(title, text, videoUrl, notificationId))
}
- private fun buildNotification(payload: PushPayload): Notification {
+ private fun buildNotification(
+ title: String,
+ text: String,
+ videoUrl: String,
+ notificationId: Int,
+ ): Notification {
val deepLink = Intent(
Intent.ACTION_VIEW,
- Uri.parse("typetype://watch?v=${toPublicWatchParameter(payload.videoUrl)}"),
+ Uri.parse("typetype://watch?v=${toPublicWatchParameter(videoUrl)}"),
)
val contentIntent = PendingIntent.getActivity(
context,
- payload.notificationId(),
+ notificationId,
deepLink,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
- val fallbackTitle = payload.channelName.ifBlank { payload.serviceName }
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
- .setContentTitle(fallbackTitle)
- .setContentText(payload.title)
- .setStyle(NotificationCompat.BigTextStyle().bigText(payload.title))
+ .setContentTitle(title)
+ .setContentText(text)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(contentIntent)
.setAutoCancel(true)
+ .setOnlyAlertOnce(true)
.build()
}
@@ -61,9 +95,10 @@ class PushNotifier @Inject constructor(
NotificationManager.IMPORTANCE_DEFAULT,
)
- private fun PushPayload.notificationId(): Int = eventId.hashCode()
-
private companion object {
const val CHANNEL_ID = "subscription_push"
}
}
+
+internal fun subscriptionNotificationId(videoId: String, videoUrl: String, fallback: String): Int =
+ videoId.trim().ifBlank { videoUrl.trim() }.ifBlank { fallback.trim() }.hashCode()
diff --git a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt
index dfaf7a77..9eacb490 100644
--- a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt
+++ b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt
@@ -49,13 +49,10 @@ class PushRegistrationManager @Inject constructor(
return update(PushRegistrationStatus.Unavailable)
}
val endpoint = registration.endpoint ?: return startRegistration(scope)
- val devices = repository.devices().getOrElse {
- return update(PushRegistrationStatus.Failed)
- }
- if (devices.any { device -> device.deviceId == registration.deviceId }) {
- return update(PushRegistrationStatus.Registered)
- }
- return repository.registerDevice(registration.deviceId, endpoint).fold(
+ val p256dh = registration.p256dh
+ val auth = registration.auth
+ if (p256dh.isNullOrBlank() || auth.isNullOrBlank()) return startRegistration(scope)
+ return repository.registerDevice(registration.deviceId, endpoint, p256dh, auth).fold(
onSuccess = { update(PushRegistrationStatus.Registered) },
onFailure = { update(PushRegistrationStatus.Failed) },
)
@@ -92,18 +89,20 @@ class PushRegistrationManager @Inject constructor(
val registration = registrationStore.registrationOnce(scope)
return when {
registration == null -> update(PushRegistrationStatus.Disabled)
- registration.endpoint != null -> update(PushRegistrationStatus.Registered)
+ !registration.endpoint.isNullOrBlank() &&
+ !registration.p256dh.isNullOrBlank() &&
+ !registration.auth.isNullOrBlank() -> update(PushRegistrationStatus.Registered)
else -> update(PushRegistrationStatus.Registering)
}
}
- fun onEndpointAvailable(instance: String, endpoint: String) {
+ fun onEndpointAvailable(instance: String, endpoint: String, p256dh: String, auth: String) {
managerScope.launch {
val accountScope = scopeFromInstanceName(instance) ?: return@launch
val deviceId = registrationStore.ensureDeviceId(accountScope)
- repository.registerDevice(deviceId, endpoint).fold(
+ repository.registerDevice(deviceId, endpoint, p256dh, auth).fold(
onSuccess = {
- registrationStore.setEndpoint(accountScope, endpoint)
+ registrationStore.setSubscription(accountScope, endpoint, p256dh, auth)
update(PushRegistrationStatus.Registered)
},
onFailure = { update(PushRegistrationStatus.Failed) },
diff --git a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt
index af093df2..d7191236 100644
--- a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt
+++ b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt
@@ -15,7 +15,12 @@ class TypeTypePushService : PushService() {
@Inject lateinit var notifier: PushNotifier
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
- registrationManager.onEndpointAvailable(instance, endpoint.url)
+ val keySet = endpoint.pubKeySet
+ if (keySet == null) {
+ registrationManager.onRegistrationFailed(instance)
+ return
+ }
+ registrationManager.onEndpointAvailable(instance, endpoint.url, keySet.pubKey, keySet.auth)
}
override fun onMessage(message: PushMessage, instance: String) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d07e1f2f..29ef1893 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
@@ -1209,6 +1211,7 @@
Install a UnifiedPush distributor such as ntfy to receive notifications.
Choose distributor
Couldn\'t update push notifications
+ The app also checks for new videos every 15 minutes
Turn on new video notifications
Turn off new video notifications
Disable battery optimization for reliable notification delivery.
diff --git a/app/src/test/java/dev/typetype/android/core/ui/components/ChannelAvatarTest.kt b/app/src/test/java/dev/typetype/android/core/ui/components/ChannelAvatarTest.kt
new file mode 100644
index 00000000..f34d55e4
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/core/ui/components/ChannelAvatarTest.kt
@@ -0,0 +1,46 @@
+package dev.typetype.android.core.ui.components
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ChannelAvatarTest {
+ @Test
+ fun `first attempt uses the original url`() {
+ assertEquals(
+ "https://example.test/avatar.png",
+ avatarRetryUrl("https://example.test/avatar.png", 0),
+ )
+ }
+
+ @Test
+ fun `retries append a distinct cache key`() {
+ assertEquals(
+ "https://example.test/avatar.png?_tt_avatar_retry=1",
+ avatarRetryUrl("https://example.test/avatar.png", 1),
+ )
+ assertEquals(
+ "https://example.test/avatar.png?_tt_avatar_retry=2",
+ avatarRetryUrl("https://example.test/avatar.png", 2),
+ )
+ }
+
+ @Test
+ fun `retries keep existing query parameters`() {
+ assertEquals(
+ "https://example.test/proxy?url=abc&_tt_avatar_retry=1",
+ avatarRetryUrl("https://example.test/proxy?url=abc", 1),
+ )
+ }
+
+ @Test
+ fun `blank urls are left untouched`() {
+ assertEquals("", avatarRetryUrl("", 2))
+ assertEquals(" ", avatarRetryUrl(" ", 2))
+ }
+
+ @Test
+ fun `initial falls back to a question mark`() {
+ assertEquals("C", avatarInitial("CodeYure"))
+ assertEquals("?", avatarInitial(" "))
+ }
+}
diff --git a/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt b/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt
new file mode 100644
index 00000000..39535ac5
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt
@@ -0,0 +1,25 @@
+package dev.typetype.android.core.url
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class PercentEncodingTest {
+ @Test
+ fun `keeps unreserved characters`() {
+ assertEquals("aZ0-_.~", percentEncode("aZ0-_.~"))
+ }
+
+ @Test
+ fun `escapes reserved query characters`() {
+ assertEquals(
+ "https%3A%2F%2Fexample.test%2Fwatch%3Fv%3Da%26b%3Dc",
+ percentEncode("https://example.test/watch?v=a&b=c"),
+ )
+ }
+
+ @Test
+ fun `escapes spaces and multibyte characters as utf8`() {
+ assertEquals("a%20b", percentEncode("a b"))
+ assertEquals("%C3%A9t%C3%A9", percentEncode("été"))
+ }
+}
diff --git a/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt b/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt
new file mode 100644
index 00000000..92f516f0
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt
@@ -0,0 +1,26 @@
+package dev.typetype.android.data.network.dto
+
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class PushDeviceRegistrationRequestDtoTest {
+ @Test
+ fun registrationJsonIncludesWebPushSubscriptionKeys() {
+ val request = PushDeviceRegistrationRequestDto(
+ deviceId = "device-1",
+ endpoint = "https://push.example/subscription",
+ p256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
+ auth = "BTBZMqHH6r4Tts7J_aSIgg",
+ )
+
+ val json = Json.parseToJsonElement(Json.encodeToString(request)).jsonObject
+
+ assertEquals(request.endpoint, json["endpoint"]?.jsonPrimitive?.content)
+ assertEquals(request.p256dh, json["p256dh"]?.jsonPrimitive?.content)
+ assertEquals(request.auth, json["auth"]?.jsonPrimitive?.content)
+ }
+}
diff --git a/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt
new file mode 100644
index 00000000..fea55273
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt
@@ -0,0 +1,130 @@
+package dev.typetype.android.data.notifications
+
+import dev.typetype.android.domain.feed.Video
+import dev.typetype.android.domain.notifications.NotificationItem
+import dev.typetype.android.domain.push.ChannelNotificationsPreference
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LocalNotificationDecisionTest {
+ @Test
+ fun `first poll records the backlog without notifying`() {
+ val decision = decideLocalNotifications(
+ items = listOf(item(videoId = "a", channelUrl = CHANNEL), item(videoId = "b", channelUrl = CHANNEL)),
+ knownKeys = emptySet(),
+ seeded = false,
+ enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)),
+ )
+
+ assertEquals(listOf("a", "b"), decision.keysToRecord)
+ assertTrue(decision.notified.isEmpty())
+ }
+
+ @Test
+ fun `alerts only new videos from enabled channels`() {
+ val decision = decideLocalNotifications(
+ items = listOf(
+ item(videoId = "new-enabled", channelUrl = CHANNEL),
+ item(videoId = "new-silent", channelUrl = OTHER_CHANNEL),
+ item(videoId = "seen", channelUrl = CHANNEL),
+ ),
+ knownKeys = setOf("seen"),
+ seeded = true,
+ enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)),
+ )
+
+ assertEquals(listOf("new-enabled", "new-silent"), decision.keysToRecord)
+ assertEquals(listOf("new-enabled"), decision.notified.map { it.video.id })
+ }
+
+ @Test
+ fun `disabled channels are recorded but never notified`() {
+ val decision = decideLocalNotifications(
+ items = listOf(item(videoId = "a", channelUrl = CHANNEL)),
+ knownKeys = emptySet(),
+ seeded = true,
+ enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = false)),
+ )
+
+ assertEquals(listOf("a"), decision.keysToRecord)
+ assertTrue(decision.notified.isEmpty())
+ }
+
+ @Test
+ fun `preference matches a channel url with an extra path segment on either side`() {
+ val trailingOnItem = decideLocalNotifications(
+ items = listOf(item(videoId = "a", channelUrl = "$CHANNEL/videos")),
+ knownKeys = emptySet(),
+ seeded = true,
+ enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)),
+ )
+ val trailingOnPreference = decideLocalNotifications(
+ items = listOf(item(videoId = "a", channelUrl = CHANNEL)),
+ knownKeys = emptySet(),
+ seeded = true,
+ enabledChannels = listOf(ChannelNotificationsPreference("$CHANNEL/videos", enabled = true)),
+ )
+
+ assertEquals(listOf("a"), trailingOnItem.notified.map { it.video.id })
+ assertEquals(listOf("a"), trailingOnPreference.notified.map { it.video.id })
+ }
+
+ @Test
+ fun `a longer channel identifier is not treated as the same channel`() {
+ val decision = decideLocalNotifications(
+ items = listOf(item(videoId = "a", channelUrl = "${CHANNEL}extra")),
+ knownKeys = emptySet(),
+ seeded = true,
+ enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)),
+ )
+
+ assertTrue(decision.notified.isEmpty())
+ }
+
+ @Test
+ fun `newest first input is notified oldest first`() {
+ val decision = decideLocalNotifications(
+ items = listOf(
+ item(videoId = "newest", channelUrl = CHANNEL),
+ item(videoId = "older", channelUrl = CHANNEL),
+ ),
+ knownKeys = emptySet(),
+ seeded = true,
+ enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)),
+ )
+
+ assertEquals(listOf("older", "newest"), decision.notified.map { it.video.id })
+ }
+
+ private fun item(videoId: String, channelUrl: String) = NotificationItem(
+ type = "subscription_new_video",
+ title = "Video $videoId",
+ createdAtMillis = 1L,
+ publishedAtMillis = 1L,
+ channelUrl = channelUrl,
+ channelName = "Channel",
+ channelAvatarUrl = "",
+ video = Video(
+ id = videoId,
+ url = "https://example.test/watch?v=$videoId",
+ title = "Video $videoId",
+ thumbnailUrl = "",
+ uploaderName = "Channel",
+ uploaderUrl = channelUrl,
+ uploaderAvatarUrl = "",
+ uploaderVerified = false,
+ durationSeconds = 0L,
+ isLive = false,
+ viewCount = 0L,
+ uploadedAtMillis = 1L,
+ isShortFormContent = false,
+ shortDescription = null,
+ ),
+ )
+
+ private companion object {
+ const val CHANNEL = "https://www.youtube.com/channel/UCaaaaaaaaaaaaaaaaaaaaaa"
+ const val OTHER_CHANNEL = "https://www.youtube.com/channel/UCbbbbbbbbbbbbbbbbbbbbbb"
+ }
+}
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/data/stream/SabrPlaybackSessionPreparerTest.kt b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt
index 0af6a50c..24c62e96 100644
--- a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt
+++ b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt
@@ -81,6 +81,30 @@ class SabrPlaybackSessionPreparerTest {
assertEquals("140", body.getValue("audioItag").jsonPrimitive.content)
}
+ @Test
+ fun prewarmCreationMarksTheSessionForReuse() = runBlocking {
+ enqueueCreate(startTimeMs = 0, ready = true)
+ enqueuePosition(0)
+ enqueuePrefetch(ready = true)
+ enqueueWindow(startTimeMs = 0, endOfStream = false)
+
+ preparer().prepare(api, baseUrl, target(), 0L, prewarm = true)
+
+ assertEquals("/api/sabr/playback/video?prewarm=true", server.takeRequest().path)
+ }
+
+ @Test
+ fun playbackCreationOmitsThePrewarmFlag() = runBlocking {
+ enqueueCreate(startTimeMs = 0, ready = true)
+ enqueuePosition(0)
+ enqueuePrefetch(ready = true)
+ enqueueWindow(startTimeMs = 0, endOfStream = false)
+
+ preparer().prepare(api, baseUrl, target(), 0L)
+
+ assertEquals("/api/sabr/playback/video", server.takeRequest().path)
+ }
+
@Test
fun refreshReportsBufferedRangesForBothSelectedTracks() = runBlocking {
enqueuePosition(55_000)
diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt
index ee6e3e7d..2724714e 100644
--- a/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt
+++ b/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt
@@ -73,6 +73,73 @@ class StreamEndpointLoaderTest {
assertEquals(1, server.requestCount)
}
+ @Test
+ fun youtubeLiveHlsResponseIsAcceptedAsLiveContract() = runBlocking {
+ server.enqueue(
+ jsonResponse(
+ sabr = false,
+ live = true,
+ hlsUrl = "/streams/hls-manifest?token=signed",
+ ),
+ )
+
+ val body = api.loadStreamResponse(YOUTUBE_URL).body()
+
+ assertFalse(body?.hasPlayableSabrContract(server.url("/").toString()) == true)
+ assertTrue(body?.hasPlayableLiveContract(server.url("/").toString()) == true)
+ assertEquals(1, server.requestCount)
+ }
+
+ @Test
+ fun youtubeLiveHlsResponseOnAnotherOriginIsRejected() = runBlocking {
+ server.enqueue(
+ jsonResponse(
+ sabr = false,
+ live = true,
+ hlsUrl = "https://media.example/live.m3u8",
+ ),
+ )
+
+ val body = api.loadStreamResponse(YOUTUBE_URL).body()
+
+ assertFalse(body?.hasPlayableLiveContract(server.url("/").toString()) == true)
+ }
+
+ @Test
+ fun youtubePlaybackFallsBackWhenTheBootstrapRouteCannotServeTheVideo() = runBlocking {
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(503)
+ .setHeader("Content-Type", "application/json")
+ .setBody("{\"error\":\"The video provider is blocking requests\",\"code\":\"provider_access_blocked\"}"),
+ )
+ server.enqueue(
+ jsonResponse(
+ sabr = false,
+ live = true,
+ hlsUrl = "/streams/hls-manifest?token=signed",
+ ),
+ )
+
+ val response = api.loadYouTubeSabrBootstrapResponse(YOUTUBE_URL)
+ val body = response.body()
+
+ assertTrue(response.isSuccessful)
+ assertTrue(body?.hasPlayableLiveContract(server.url("/").toString()) == true)
+ assertEquals("/streams/youtube/sabr/bootstrap?url=${encode(YOUTUBE_URL)}", server.takeRequest().path)
+ assertEquals("/streams/youtube/sabr?url=${encode(YOUTUBE_URL)}", server.takeRequest().path)
+ }
+
+ @Test
+ fun youtubePlaybackKeepsABootstrapFailureThatTheSabrRouteCannotFix() = runBlocking {
+ server.enqueue(errorResponse(401))
+
+ val response = api.loadYouTubeSabrBootstrapResponse(YOUTUBE_URL)
+
+ assertEquals(401, response.code())
+ assertEquals(1, server.requestCount)
+ }
+
@Test
fun youtubeSabrResponseWithoutValidItagsIsRejected() = runBlocking {
server.enqueue(jsonResponse(sabr = true, videoItag = 0))
@@ -162,6 +229,8 @@ class StreamEndpointLoaderTest {
private fun jsonResponse(
sabr: Boolean,
hls: Boolean = false,
+ live: Boolean = false,
+ hlsUrl: String? = null,
videoItag: Int = 137,
manifestUrl: String = "/sabr/manifest/video",
videoCodec: String = "avc1.640028",
@@ -170,7 +239,18 @@ class StreamEndpointLoaderTest {
): MockResponse = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
- .setBody(streamJson(sabr, hls, videoItag, manifestUrl, videoCodec, audioMimeType, audioCodec))
+ .setBody(
+ streamJson(
+ sabr = sabr,
+ live = live,
+ hlsUrlValue = hlsUrl ?: if (hls) "https://media.example/live.m3u8" else "",
+ videoItag = videoItag,
+ manifestUrl = manifestUrl,
+ videoCodec = videoCodec,
+ audioMimeType = audioMimeType,
+ audioCodec = audioCodec,
+ ),
+ )
private fun errorResponse(code: Int): MockResponse = MockResponse()
.setResponseCode(code)
@@ -179,7 +259,8 @@ class StreamEndpointLoaderTest {
private fun streamJson(
sabr: Boolean,
- hls: Boolean,
+ live: Boolean,
+ hlsUrlValue: String,
videoItag: Int,
manifestUrl: String,
videoCodec: String,
@@ -189,7 +270,6 @@ class StreamEndpointLoaderTest {
val delivery = if (sabr) "sabr" else "progressive"
val manifest = if (sabr) "\"$manifestUrl\"" else "null"
val mediaUrl = if (sabr) "" else "https://media.example/video.mp4"
- val hlsUrl = if (hls) "https://media.example/live.m3u8" else ""
return """
{
"id":"video","title":"Video","uploaderName":"Channel","uploaderUrl":"/channel",
@@ -197,7 +277,8 @@ class StreamEndpointLoaderTest {
"viewCount":1,"likeCount":0,"dislikeCount":0,"uploadDate":"","uploaded":-1,
"uploaderSubscriberCount":0,"uploaderVerified":false,"category":"","license":"",
"visibility":"public","streamType":"VIDEO_STREAM","isShortFormContent":false,
- "requiresMembership":false,"startPosition":0,"hlsUrl":"$hlsUrl","dashMpdUrl":"",
+ "requiresMembership":false,"startPosition":0,"hlsUrl":"$hlsUrlValue","dashMpdUrl":"",
+ "isLive":$live,"hasLiveManifest":$live,
"videoStreams":[],
"videoOnlyStreams":[{"url":"$mediaUrl","mimeType":"video/mp4","format":"MPEG_4",
"resolution":"1080p","codec":"$videoCodec","isVideoOnly":true,"itag":$videoItag,
diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt
new file mode 100644
index 00000000..83d8be37
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt
@@ -0,0 +1,201 @@
+package dev.typetype.android.data.stream
+
+import dev.typetype.android.core.error.CodedFailure
+import dev.typetype.android.data.account.AccountScope
+import dev.typetype.android.data.account.AccountScopeProvider
+import dev.typetype.android.data.network.AlwaysAvailablePlaybackNetworkObserver
+import dev.typetype.android.data.network.dto.AudioStreamItem
+import dev.typetype.android.data.network.dto.StreamResponse
+import dev.typetype.android.data.network.dto.VideoStreamItem
+import dev.typetype.android.domain.server.Server
+import dev.typetype.android.domain.server.ServerRepository
+import dev.typetype.android.domain.stream.StreamPlaybackContract
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import retrofit2.Response
+
+class StreamRepositoryLiveTest {
+ @Test
+ fun `youtube live hls response resolves the signed server manifest`() = runBlocking {
+ val repository = repository(
+ response(
+ isLive = true,
+ hasLiveManifest = true,
+ hlsUrl = LIVE_HLS_URL,
+ ),
+ )
+
+ val stream = repository.loadPlaybackStream(VIDEO_URL).getOrThrow()
+
+ assertEquals(StreamPlaybackContract.ProviderMedia, stream.playbackContract)
+ assertEquals("$BASE_URL$LIVE_HLS_PATH", stream.hlsUrl)
+ assertNull(stream.serverSabrManifestUrl)
+ assertNull(stream.serverHlsManifestUrl)
+ assertNull(stream.serverDashManifestUrl)
+ assertTrue(stream.isLive)
+ assertTrue(stream.sabrVideoStreams.isEmpty())
+ }
+
+ @Test
+ fun `youtube live response without a server manifest is rejected`() = runBlocking {
+ val repository = repository(
+ response(
+ isLive = true,
+ hasLiveManifest = true,
+ hlsUrl = "https://media.example/live.m3u8",
+ ),
+ )
+
+ val failure = repository.loadPlaybackStream(VIDEO_URL).exceptionOrNull()
+
+ assertEquals("youtube_sabr_unavailable", (failure as CodedFailure).failureCode)
+ }
+
+ @Test
+ fun `youtube video without sabr or live manifest is rejected`() = runBlocking {
+ val repository = repository(response(isLive = false, hasLiveManifest = false, hlsUrl = ""))
+
+ val failure = repository.loadPlaybackStream(VIDEO_URL).exceptionOrNull()
+
+ assertEquals("youtube_sabr_unavailable", (failure as CodedFailure).failureCode)
+ }
+
+ @Test
+ fun `youtube vod keeps the sabr contract and drops provider manifests`() = runBlocking {
+ val repository = repository(
+ response(
+ isLive = false,
+ hasLiveManifest = false,
+ hlsUrl = "",
+ sabrFormats = true,
+ ),
+ )
+
+ val stream = repository.loadPlaybackStream(VIDEO_URL).getOrThrow()
+
+ assertEquals(StreamPlaybackContract.ServerSabr, stream.playbackContract)
+ assertNull(stream.hlsUrl)
+ assertEquals("$BASE_URL${SABR_MANIFEST_PATH.substringAfter('/')}", stream.serverSabrManifestUrl)
+ }
+
+ private fun repository(response: StreamResponse) = StreamRepositoryImpl(
+ remoteSource = FixedStreamRemoteSource(response),
+ activeAccountScope = FixedAccountScope,
+ serverRepository = FixedServerRepository,
+ networkMonitor = AlwaysAvailablePlaybackNetworkObserver,
+ )
+
+ private class FixedStreamRemoteSource(
+ private val response: StreamResponse,
+ ) : StreamRemoteSource {
+ override suspend fun load(
+ scope: AccountScope,
+ videoUrl: String,
+ provider: StreamProvider,
+ playbackBootstrap: Boolean,
+ ): Response = Response.success(response)
+ }
+
+ private object FixedAccountScope : AccountScopeProvider {
+ private val scope = AccountScope(SERVER_ID, ACCOUNT_ID)
+
+ override fun observe(): Flow = flowOf(scope)
+
+ override suspend fun require(): AccountScope = scope
+
+ override suspend fun verify(expected: AccountScope) {
+ check(expected == scope)
+ }
+ }
+
+ private object FixedServerRepository : ServerRepository {
+ private val server = Server(SERVER_ID, BASE_URL, "Instance", 0L)
+
+ override fun observeServers(): Flow> = flowOf(listOf(server))
+ override fun observeCurrentServer(): Flow = flowOf(server)
+ override suspend fun getServer(id: String): Server? = server.takeIf { id == SERVER_ID }
+ override suspend fun addServer(server: Server) = Unit
+ override suspend fun deleteServer(id: String) = Unit
+ override suspend fun setCurrentServer(id: String) = Unit
+ override suspend fun clearCurrentServer() = Unit
+ }
+
+ private fun response(
+ isLive: Boolean,
+ hasLiveManifest: Boolean,
+ hlsUrl: String,
+ sabrFormats: Boolean = false,
+ ) = StreamResponse(
+ id = "video",
+ title = "Video",
+ uploaderName = "Channel",
+ uploaderUrl = "/channel",
+ uploaderAvatarUrl = "",
+ thumbnailUrl = "",
+ description = "",
+ duration = if (isLive) 0L else 60L,
+ viewCount = 1L,
+ likeCount = 0L,
+ dislikeCount = 0L,
+ uploadDate = "",
+ uploaded = 0L,
+ uploaderSubscriberCount = 1L,
+ uploaderVerified = false,
+ category = "",
+ license = "",
+ visibility = "public",
+ streamType = if (isLive) "LIVE_STREAM" else "VIDEO_STREAM",
+ isShortFormContent = false,
+ requiresMembership = false,
+ isLive = isLive,
+ hasLiveManifest = hasLiveManifest,
+ startPosition = 0L,
+ hlsUrl = hlsUrl,
+ dashMpdUrl = "",
+ videoOnlyStreams = if (sabrFormats) listOf(videoStream()) else emptyList(),
+ audioStreams = if (sabrFormats) listOf(audioStream()) else emptyList(),
+ )
+
+ private fun videoStream() = VideoStreamItem(
+ url = "",
+ mimeType = "video/mp4",
+ format = "MPEG_4",
+ resolution = "720p",
+ codec = "avc1.64001f",
+ isVideoOnly = true,
+ itag = 136,
+ width = 1280,
+ height = 720,
+ fps = 30,
+ contentLength = 1L,
+ deliveryMethod = "sabr",
+ manifestUrl = SABR_MANIFEST_PATH,
+ )
+
+ private fun audioStream() = AudioStreamItem(
+ url = "",
+ mimeType = "audio/mp4",
+ format = "MPEG_4",
+ codec = "mp4a.40.2",
+ itag = 140,
+ contentLength = 1L,
+ isOriginal = true,
+ deliveryMethod = "sabr",
+ manifestUrl = SABR_MANIFEST_PATH,
+ )
+
+ private companion object {
+ const val SERVER_ID = "server"
+ const val ACCOUNT_ID = "account"
+ const val BASE_URL = "https://instance.example/api/"
+ const val VIDEO_URL = "https://www.youtube.com/watch?v=video"
+ const val SABR_MANIFEST_PATH = "/sabr/manifest/video"
+ const val LIVE_HLS_PATH = "streams/hls-manifest?token=signed"
+ const val LIVE_HLS_URL = "/$LIVE_HLS_PATH"
+ }
+}
diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt
new file mode 100644
index 00000000..4b281bbd
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt
@@ -0,0 +1,203 @@
+package dev.typetype.android.data.stream
+
+import dev.typetype.android.data.account.AccountScope
+import dev.typetype.android.data.account.AccountScopeProvider
+import dev.typetype.android.data.network.AlwaysAvailablePlaybackNetworkObserver
+import dev.typetype.android.data.network.dto.AudioStreamItem
+import dev.typetype.android.data.network.dto.StreamResponse
+import dev.typetype.android.data.network.dto.VideoStreamItem
+import dev.typetype.android.domain.server.Server
+import dev.typetype.android.domain.server.ServerRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import retrofit2.Response
+
+class StreamRepositoryProviderMediaTest {
+ @Test
+ fun `niconico media handles resolve and keep hls playback container`() = runBlocking {
+ val repository = repository(
+ NICO_URL,
+ response(
+ hlsVideoHandles = true,
+ videoHandles = listOf(HANDLE_A, HANDLE_B),
+ audioHandles = listOf(HANDLE_AUDIO),
+ ),
+ )
+
+ val stream = repository.loadPlaybackStream(NICO_URL).getOrThrow()
+ val video = stream.videoOnlyStreams.first()
+
+ assertEquals("$BASE_URL${HANDLE_A.substringAfter('/')}", video.url)
+ assertEquals("application/vnd.apple.mpegurl", video.playbackMimeType)
+ assertEquals(360, video.height)
+ assertEquals("$BASE_URL${HANDLE_AUDIO.substringAfter('/')}", stream.audioStreams.first().url)
+ assertNull(stream.hlsUrl)
+ }
+
+ @Test
+ fun `bilibili progressive handles resolve without a playback container`() = runBlocking {
+ val repository = repository(
+ BILI_URL,
+ response(
+ hlsVideoHandles = false,
+ videoHandles = listOf(HANDLE_A),
+ audioHandles = listOf(HANDLE_AUDIO),
+ height = 384,
+ codec = "avc1.64001E",
+ ),
+ )
+
+ val stream = repository.loadPlaybackStream(BILI_URL).getOrThrow()
+
+ assertEquals("$BASE_URL${HANDLE_A.substringAfter('/')}", stream.videoOnlyStreams.first().url)
+ assertNull(stream.videoOnlyStreams.first().playbackMimeType)
+ assertEquals(384, stream.videoOnlyStreams.first().height)
+ }
+
+ @Test
+ fun `absolute provider media url is preserved`() = runBlocking {
+ val repository = repository(
+ NICO_URL,
+ response(
+ hlsVideoHandles = false,
+ videoHandles = listOf(),
+ audioHandles = listOf(),
+ absoluteUrl = "https://cdn.example/media.m3u8",
+ ),
+ )
+
+ val stream = repository.loadPlaybackStream(NICO_URL).getOrThrow()
+
+ assertEquals("https://cdn.example/media.m3u8", stream.videoOnlyStreams.first().url)
+ }
+
+ private fun repository(videoUrl: String, response: StreamResponse) = StreamRepositoryImpl(
+ remoteSource = FixedStreamRemoteSource(videoUrl, response),
+ activeAccountScope = FixedAccountScope,
+ serverRepository = FixedServerRepository,
+ networkMonitor = AlwaysAvailablePlaybackNetworkObserver,
+ )
+
+ private class FixedStreamRemoteSource(
+ private val expectedUrl: String,
+ private val response: StreamResponse,
+ ) : StreamRemoteSource {
+ override suspend fun load(
+ scope: AccountScope,
+ videoUrl: String,
+ provider: StreamProvider,
+ playbackBootstrap: Boolean,
+ ): Response {
+ check(videoUrl == expectedUrl)
+ return Response.success(response)
+ }
+ }
+
+ private object FixedAccountScope : AccountScopeProvider {
+ private val scope = AccountScope(SERVER_ID, ACCOUNT_ID)
+
+ override fun observe(): Flow = flowOf(scope)
+
+ override suspend fun require(): AccountScope = scope
+
+ override suspend fun verify(expected: AccountScope) {
+ check(expected == scope)
+ }
+ }
+
+ private object FixedServerRepository : ServerRepository {
+ private val server = Server(SERVER_ID, BASE_URL, "Instance", 0L)
+
+ override fun observeServers(): Flow> = flowOf(listOf(server))
+ override fun observeCurrentServer(): Flow = flowOf(server)
+ override suspend fun getServer(id: String): Server? = server.takeIf { id == SERVER_ID }
+ override suspend fun addServer(server: Server) = Unit
+ override suspend fun deleteServer(id: String) = Unit
+ override suspend fun setCurrentServer(id: String) = Unit
+ override suspend fun clearCurrentServer() = Unit
+ }
+
+ private fun response(
+ hlsVideoHandles: Boolean,
+ videoHandles: List,
+ audioHandles: List,
+ height: Int = 0,
+ codec: String? = null,
+ absoluteUrl: String? = null,
+ ) = StreamResponse(
+ id = "video",
+ title = "Video",
+ uploaderName = "Channel",
+ uploaderUrl = "/channel",
+ uploaderAvatarUrl = "",
+ thumbnailUrl = "",
+ description = "",
+ duration = 320L,
+ viewCount = 1L,
+ likeCount = 0L,
+ dislikeCount = 0L,
+ uploadDate = "",
+ uploaded = 0L,
+ uploaderSubscriberCount = 1L,
+ uploaderVerified = false,
+ category = "",
+ license = "",
+ visibility = "public",
+ streamType = "VIDEO_STREAM",
+ isShortFormContent = false,
+ requiresMembership = false,
+ startPosition = 0L,
+ hlsUrl = "",
+ dashMpdUrl = "",
+ videoOnlyStreams = (videoHandles + listOfNotNull(absoluteUrl)).map { handle ->
+ videoStream(handle, hlsVideoHandles, height, codec)
+ },
+ audioStreams = audioHandles.map { handle -> audioStream(handle) },
+ )
+
+ private fun videoStream(
+ url: String,
+ hls: Boolean,
+ height: Int,
+ codec: String?,
+ ) = VideoStreamItem(
+ url = url,
+ mimeType = "video/mp4",
+ format = "MPEG-4",
+ resolution = "360p",
+ codec = codec,
+ isVideoOnly = true,
+ itag = -1,
+ width = 0,
+ height = height,
+ fps = 0,
+ contentLength = 0L,
+ deliveryMethod = if (hls) "hls" else "progressive",
+ )
+
+ private fun audioStream(url: String) = AudioStreamItem(
+ url = url,
+ mimeType = "audio/mp4",
+ format = "m4a",
+ codec = null,
+ itag = -1,
+ contentLength = 0L,
+ isOriginal = false,
+ deliveryMethod = "progressive",
+ )
+
+ private companion object {
+ const val SERVER_ID = "server"
+ const val ACCOUNT_ID = "account"
+ const val BASE_URL = "https://instance.example/api/"
+ const val NICO_URL = "https://www.nicovideo.jp/watch/sm9"
+ const val BILI_URL = "https://www.bilibili.com/video/BV1xx411c7mD"
+ const val HANDLE_A = "/media/m1_WcTj0wQ0RcWaByyoMvP8E0ak"
+ const val HANDLE_B = "/media/m1_vyvt1J_-qiWsTYEyHrEP_q-D"
+ const val HANDLE_AUDIO = "/media/m1_X9jjkzoAVheGT2QYaOYSR08A"
+ }
+}
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/DanmakuSupportTest.kt b/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt
index de755238..ea1b6510 100644
--- a/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt
+++ b/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt
@@ -12,9 +12,17 @@ class DanmakuSupportTest {
assertTrue(supportsServerBulletComments("https://nico.ms/sm9"))
}
+ @Test
+ fun acceptsSupportedBiliBiliHosts() {
+ assertTrue(supportsServerBulletComments("https://www.bilibili.com/video/BV14whX66Eka?p=1"))
+ assertTrue(supportsServerBulletComments("https://bilibili.com/video/BV14whX66Eka"))
+ assertTrue(supportsServerBulletComments("https://b23.tv/abc123"))
+ }
+
@Test
fun rejectsLookalikeAndUnrelatedHosts() {
assertFalse(supportsServerBulletComments("https://nicovideo.jp.example.com/watch/sm9"))
+ assertFalse(supportsServerBulletComments("https://bilibili.com.example.com/video/BV1"))
assertFalse(supportsServerBulletComments("https://youtube.com/watch?v=sm9"))
assertFalse(supportsServerBulletComments("not a url"))
}
diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt
index 0a1981ca..2b6b11c2 100644
--- a/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt
+++ b/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt
@@ -4,6 +4,7 @@ import dev.typetype.android.domain.stream.StreamAudioSource
import dev.typetype.android.domain.stream.Stream
import dev.typetype.android.domain.stream.StreamPlaybackContract
import dev.typetype.android.domain.stream.StreamVideoSource
+import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -257,6 +258,34 @@ class PlaybackSourceSelectorTest {
assertEquals(french, selected)
}
+ @Test
+ fun `provider video with separate audio merges without an explicit audio key`() = runBlocking {
+ val video = video("video", height = 360, codec = "avc1.64001E")
+ .copy(playbackMimeType = "application/vnd.apple.mpegurl")
+ val audio = audio("audio", locale = "ja")
+ val support = FakeCodecSupport(
+ video = mapOf(video.url to DecoderSupport.Hardware),
+ audio = mapOf(audio.url to DecoderSupport.Hardware),
+ )
+
+ val source = pickPlayableSource(
+ stream = providerStream(listOf(video), listOf(audio)),
+ selectedQuality = RECOMMENDED_QUALITY_KEY,
+ selectedAudioKey = null,
+ defaultAudioLanguage = "en",
+ automaticQualityCap = RECOMMENDED_QUALITY_KEY,
+ preferOriginalLanguage = false,
+ codecSupport = support,
+ prepareSabrPlayback = { _, _, _ -> null },
+ )
+
+ requireNotNull(source)
+ assertEquals("video", source.url)
+ assertEquals("audio", source.audioUrl)
+ assertEquals("application/vnd.apple.mpegurl", source.mimeType)
+ assertEquals("audio/mp4", source.audioMimeType)
+ }
+
private fun video(url: String, height: Int, codec: String, itag: Int = 0) = StreamVideoSource(
url = url,
mimeType = "video/mp4",
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/error/StreamErrorClassifierTest.kt b/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt
index bf3c4989..1255c29f 100644
--- a/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt
+++ b/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt
@@ -116,6 +116,36 @@ class StreamErrorClassifierTest {
assertEquals(StreamErrorKind.MemberOnly, classifyStreamError(members).kind)
}
+ @Test
+ fun `classifies scheduled lives the server reports as not started`() {
+ val scheduledLive = ServerResponseException(
+ ServerError("This live event will begin in 55 minutes.", "live_not_started", 409, "request-9"),
+ )
+
+ val classified = classifyStreamError(scheduledLive)
+
+ assertEquals(StreamErrorKind.ScheduledPremiere, classified.kind)
+ assertEquals("request-9", classified.requestId)
+ assertEquals(
+ StreamErrorKind.ScheduledPremiere,
+ classifyStreamError("This live event will begin in 55 minutes.").kind,
+ )
+ }
+
+ @Test
+ fun `maps typed provider failures to content unavailable`() {
+ val missing = ServerResponseException(
+ ServerError("This live stream recording is not available.", "content_unavailable", 404, "request-7"),
+ )
+ val blocked = ServerResponseException(
+ ServerError("Provider blocked this instance", "provider_access_blocked", 403, "request-8"),
+ )
+
+ assertEquals(StreamErrorKind.ContentUnavailable, classifyStreamError(missing).kind)
+ assertEquals("request-7", classifyStreamError(missing).requestId)
+ assertEquals(StreamErrorKind.ContentUnavailable, classifyStreamError(blocked).kind)
+ }
+
@Test
fun `finds network failures wrapped by another layer`() {
val failure = IllegalStateException("Request failed", IOException("socket closed"))
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/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt b/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt
new file mode 100644
index 00000000..a0a5c91b
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt
@@ -0,0 +1,34 @@
+package dev.typetype.android.services.push
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class SubscriptionNotificationIdTest {
+ @Test
+ fun `id follows the video id`() {
+ assertEquals(
+ "TIcVuMOK78I".hashCode(),
+ subscriptionNotificationId("TIcVuMOK78I", "https://www.youtube.com/watch?v=TIcVuMOK78I", "event"),
+ )
+ }
+
+ @Test
+ fun `id falls back to the video url when the id is blank`() {
+ val url = "https://www.youtube.com/watch?v=TIcVuMOK78I"
+ assertEquals(url.hashCode(), subscriptionNotificationId(" ", url, "event"))
+ }
+
+ @Test
+ fun `id falls back to the event key when both video fields are blank`() {
+ assertEquals("event".hashCode(), subscriptionNotificationId("", " ", "event"))
+ }
+
+ @Test
+ fun `push and local notifications share the id of the same video`() {
+ val pushId = subscriptionNotificationId("abc123", "https://example.test/watch?v=abc123", "event")
+ val localId = subscriptionNotificationId("abc123", "https://example.test/watch?v=abc123", "abc123")
+ assertEquals(pushId, localId)
+ assertNotEquals(pushId, subscriptionNotificationId("other", "https://example.test/watch?v=other", "event"))
+ }
+}
diff --git a/deploy/fdroid-beta/webhook.py b/deploy/fdroid-beta/webhook.py
index bbc1bac8..b90992fb 100644
--- a/deploy/fdroid-beta/webhook.py
+++ b/deploy/fdroid-beta/webhook.py
@@ -2,6 +2,8 @@
import hashlib
import hmac
import json
+import threading
+import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -9,6 +11,13 @@
SECRET_FILE = Path("/srv/fdroid-beta/secrets/github-webhook-secret")
TRIGGER_FILE = Path("/run/typetype-fdroid-beta-webhook/sync")
MAX_PAYLOAD_SIZE = 1024 * 1024
+RETRY_DELAY_SECONDS = 90
+
+
+def retrigger_sync() -> None:
+ time.sleep(RETRY_DELAY_SECONDS)
+ TRIGGER_FILE.unlink(missing_ok=True)
+ TRIGGER_FILE.touch(exist_ok=True)
class WebhookHandler(BaseHTTPRequestHandler):
@@ -58,6 +67,7 @@ def do_POST(self):
and release.get("prerelease", False)
):
TRIGGER_FILE.touch(exist_ok=True)
+ threading.Thread(target=retrigger_sync, daemon=True).start()
self.send_response(202)
else:
self.send_response(204)
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"
diff --git a/release-notes/v1.9.0-beta.1.md b/release-notes/v1.9.0-beta.1.md
new file mode 100644
index 00000000..b47ba74f
--- /dev/null
+++ b/release-notes/v1.9.0-beta.1.md
@@ -0,0 +1,69 @@
+# TypeType for Android 1.9.0-beta.1
+
+TypeType for Android 1.9.0-beta.1 fixes provider playback, brings subscription notifications that also work without a push distributor, adds previous and next video controls, and aligns the loading placeholders with the web client.
+
+This is a beta build on the 1.9 line.
+
+## Playback and providers
+
+- Play YouTube lives from the server live manifest, so an active live starts instead of failing on a missing SABR contract.
+- Fall back to the SABR stream route when the SABR bootstrap cannot serve a video.
+- Play Niconico and BiliBili provider media handles, including the playback container and the height read from the resolution label, and keep merged video and audio working.
+- Report typed provider failures as unavailable content instead of a generic playback error.
+- Label scheduled lives as not started instead of reporting a delivery failure.
+- Remove the thick black bar above the player during the pull-down gesture. [#50](https://github.com/TypeType-Video/TypeType-Android/issues/50)
+
+## Player controls
+
+- Add previous and next video controls to the player. [#87](https://github.com/TypeType-Video/TypeType-Android/issues/87)
+- Keep the favorite and watch later state in the player synchronized with the library.
+
+## Loading placeholders
+
+- Show card placeholders while the home, search, and subscription feeds load, instead of a blank area or a lone spinner.
+- Show details placeholders while a video loads, covering the title, channel, subscribe button, actions, and description.
+- Show channel avatars with a placeholder while they load, and retry a failed avatar up to two times before falling back to the channel initial.
+
+## Bullet comments
+
+- Enable bullet comments on BiliBili, matching the web client, in addition to NicoNico.
+
+## Notifications
+
+- Add subscription notifications with UnifiedPush, a channel bell, and a dedicated notification settings screen. [#51](https://github.com/TypeType-Video/TypeType-Android/issues/51)
+- Persist the push capability with subscriptions so the bell appears on subscribed channels. [#86](https://github.com/TypeType-Video/TypeType-Android/issues/86)
+- Deliver subscription notifications locally every fifteen minutes, including when UnifiedPush is registered, so a silent push no longer means no notification.
+
+## Appearance
+
+- Grey out the accent color picker when the Dynamic theme is selected, since the system owns the accent in that mode. [#72](https://github.com/TypeType-Video/TypeType-Android/issues/72)
+
+## Performance
+
+- Prewarm SABR playback when a video card, an autoplay preload, or a Shorts preload is shown, with serialized requests.
+- Skip SABR prewarm for live videos, which previously triggered a full extraction that was then discarded.
+
+## Compatibility
+
+- Keep Android 6.0 through Android 17 support, API 23 through API 37.
+- Fix a crash on Android 6.0 caused by the JVM URL encoder.
+
+## Thx
+
+Thx to @therealresonix for the player gesture, fullscreen, and appearance reports. [#50](https://github.com/TypeType-Video/TypeType-Android/issues/50) [#72](https://github.com/TypeType-Video/TypeType-Android/issues/72)
+
+Thx to @ollieisonline for the notification bell and push notification reports. [#51](https://github.com/TypeType-Video/TypeType-Android/issues/51) [#86](https://github.com/TypeType-Video/TypeType-Android/issues/86)
+
+Thx to @303inmyheart for the player navigation report. [#87](https://github.com/TypeType-Video/TypeType-Android/issues/87)
+
+Thx to @filippobaroni for the comment rendering report. [#82](https://github.com/TypeType-Video/TypeType-Android/issues/82)
+
+Thx as well to everyone testing the beta builds, sharing diagnostics, and helping TypeType Android improve.
+
+## Installing
+
+Download the APK attached below, or use the beta channel of the TypeType F-Droid repository. The APK is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it.
+
+The stable and beta F-Droid channels use the same application identity, so switching channels updates the existing installation.
+
+**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.8.1-beta.2...v1.9.0-beta.1
diff --git a/release-notes/v1.9.0-beta.2.md b/release-notes/v1.9.0-beta.2.md
new file mode 100644
index 00000000..cc3b2109
--- /dev/null
+++ b/release-notes/v1.9.0-beta.2.md
@@ -0,0 +1,33 @@
+# TypeType for Android 1.9.0-beta.2
+
+TypeType for Android 1.9.0-beta.2 fixes channel avatars that disappeared after loading, and brings the loading animation of the web client to thumbnails as well as avatars.
+
+This is a beta build on the 1.9 line.
+
+## Loading placeholders
+
+- Keep channel avatars visible once loaded, after a beta.1 regression that removed them from the layout.
+- Animate video thumbnails with the same pulsing placeholder as avatars while they load, on video cards, related videos, continue watching, playlists, and the horizontal cards.
+- Use one skeleton primitive across the application, tinted from the foreground color like the web client, instead of the previous surface tint.
+- Retry a failed channel avatar up to two times with a distinct cache key, then fall back to the channel initial.
+
+## Everything from beta.1
+
+- Play YouTube lives from the server live manifest, fall back to the SABR route when the bootstrap cannot serve a video, and play Niconico and BiliBili provider media handles.
+- Add previous and next video controls to the player, and keep the favorite and watch later state synchronized with the library.
+- Show card placeholders while the home, search, and subscription feeds load, and details placeholders while a video loads.
+- Enable bullet comments on BiliBili, and label scheduled lives as not started instead of reporting a delivery failure.
+- Add subscription notifications with UnifiedPush, plus a local check every fifteen minutes.
+- Grey out the accent color picker when the Dynamic theme is selected.
+- Prewarm SABR playback when a card is shown, and skip that prewarm for live videos.
+- Keep Android 6.0 through Android 17 support, API 23 through API 37, and fix a crash on Android 6.0 caused by the JVM URL encoder.
+
+## Thx
+
+Thx to @therealresonix, @ollieisonline, @303inmyheart, and @filippobaroni for the reports that shaped this line, and to everyone testing the beta builds.
+
+## Installing
+
+Download the APK attached below, or use the beta channel of the TypeType F-Droid repository. The APK is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it.
+
+**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.9.0-beta.1...v1.9.0-beta.2
diff --git a/release-notes/v1.9.0-beta.3.md b/release-notes/v1.9.0-beta.3.md
new file mode 100644
index 00000000..9273d633
--- /dev/null
+++ b/release-notes/v1.9.0-beta.3.md
@@ -0,0 +1,19 @@
+# TypeType for Android 1.9.0-beta.3
+
+TypeType for Android 1.9.0-beta.3 sends the subscription keys required by the server to encrypt UnifiedPush notifications.
+
+## Notifications
+
+- Register the endpoint with its Web Push `p256dh` public key and `auth` secret, scoped to the current instance and account.
+- Keep registration inactive if the distributor does not provide the encryption keys.
+- Please verify delivery with Sunup by enabling a channel bell and waiting for a new video notification.
+
+## Thx
+
+Thx to everyone testing beta notifications and reporting that they did not arrive.
+
+## Installing
+
+Download the APK attached below, or use the beta channel of the TypeType F-Droid repository. The APK is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it.
+
+**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.9.0-beta.2...v1.9.0-beta.3
diff --git a/release-notes/v1.9.0.md b/release-notes/v1.9.0.md
new file mode 100644
index 00000000..8fe8e877
--- /dev/null
+++ b/release-notes/v1.9.0.md
@@ -0,0 +1,63 @@
+# TypeType for Android 1.9.0
+
+TypeType for Android 1.9.0 brings more reliable playback and subscription notifications, smoother loading states, and new player controls. It also moves the Android app from beta to stable.
+
+## Search
+
+- Switch between YouTube, BiliBili, and NicoNico directly from search.
+- Keep the selected service in effect while searching and browsing suggestions.
+
+## Playback and providers
+
+- Play YouTube live streams from the server-provided live manifest, with a fallback when the SABR bootstrap cannot serve a video.
+- Play Niconico and BiliBili media handles with the correct video and audio formats.
+- Add previous and next video controls, and keep favorite and watch later state synchronized with the library.
+- Prewarm SABR playback for video cards, autoplay, and Shorts to help playback start sooner, while skipping unnecessary prewarm work for live videos.
+- Show provider access failures as unavailable content and label scheduled live streams as not started.
+- Improve player transitions and remove the black strip during the pull-down gesture.
+
+## Loading and appearance
+
+- Use animated skeleton placeholders for feed cards, thumbnails, video details, and channel avatars instead of blank areas while content loads.
+- Retry failed channel avatars and keep loaded avatars visible while lists update.
+- Keep the accent color control disabled when Dynamic theme is selected.
+
+## Comments
+
+- Enable bullet comments on BiliBili, alongside NicoNico.
+
+## Notifications
+
+- Add subscription notifications with UnifiedPush, a channel bell, and a dedicated notification settings screen.
+- Register the Web Push encryption keys required by the server to deliver UnifiedPush notifications.
+- Check for new subscription videos locally every fifteen minutes, including when UnifiedPush is registered.
+
+## Compatibility
+
+- Support Android 6.0 through Android 17, API 23 through API 37.
+- Fix a crash on Android 6.0 caused by URL encoding.
+- Keep core behavior independent from Google Play Services.
+
+## Thx
+
+Thx to @therealresonix for the player gesture, fullscreen, and appearance reports. [\#50]() [\#72]()
+
+Thx to @ollieisonline for the notification bell and push notification reports. [\#51]() [\#86]()
+
+Thx to @303inmyheart for the previous and next player controls report. [\#87]()
+
+Thx to @filippobaroni for the comment rendering report. [\#82]()
+
+Thx as well to everyone testing the beta builds, sharing diagnostics, and helping TypeType Android improve.
+
+## Installing
+
+Download the APK attached below, or install TypeType through the stable channel of the TypeType F-Droid repository. The APK is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it.
+
+The stable and beta F-Droid channels use the same application identity. Switching channels updates the existing installation instead of installing a second TypeType application.
+
+If something does not work correctly, please open an issue in the [TypeType-Android repository]().
+
+If you want to support TypeType Android, sharing it with others is the best way to help. GitHub Sponsors also helps cover the server, domain, and coffee costs that keep the project moving.
+
+**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.8.0...v1.9.0