diff --git a/.agent/rules/project-context.md b/.agent/rules/project-context.md
new file mode 100644
index 000000000..8a968c5e7
--- /dev/null
+++ b/.agent/rules/project-context.md
@@ -0,0 +1,42 @@
+---
+trigger: always_on
+---
+
+# Project Context and Guidelines
+
+## About Project
+
+This is an offline-first Android client app for the [audiobookshelf](https://github.com/advplyr/audiobookshelf) server.
+
+## Feature Requirements (Offline first behavior)
+
+- As the app is an offline-first app, assume that the server is not always reachable.
+- Playback progress should be saved in the local database first **immediately**.
+- The app should watch the network changes, such as connecting to a new wifi network, or Ethernet LAN, or disconnecting from a network and connecting to a new network, disconnecting from wifi and connecting to celular network, etc., and try to ping or reach out to the audiobook server to check whether the server is reachable.
+- If the server is reachable, the app should sync the local progress to the server and pull the latest progress updates from the server to the local database; it should merge the updates from both.
+- If the server is reachable, and some chapters of any audiobook are downloaded, i.e., available offline, then the offline track should be given priority for playback.
+- If the offline track is deleted/cleared while the book is being played, the player should attempt to fallback to the online URL if the server is reachable, otherwise pause playback and persist the last playback state (track ID, playback position/timestamp, current chapter/index, and playback status) plus a flag indicating offline content was removed; ensure the rule notes that these persisted fields are used to resume or report playback state and are written atomically to the player state store.
+- The app must be fully functional for downloaded content when offline.
+
+## Ensure Stability
+
+- Ensure null-safety when converting data (e.g., check for division by zero in percentage calculations).
+- Changes must be verified by building the app and ensuring logic holds (e.g., uninstall/reinstall for clean state tests).
+
+## Overall Functionality
+
+- When the app loads, it should load the offline available book content immediately, then in the background reach out to the server (if the server is reachable) and fetch the full list of the books, continue listening section books and update the UI seemlessly.
+- The app should cache all the book's metadata in local database to optimise the app load time, Only the chapters / audio tracks should not be cached automatically / by default. Chapters should be downloaded and cached on demand by the user, using the download chapters/book functionality.
+- When the app loads, if the server is not reachable, it shouldn't show long loading screen, trying to fetch the books from the server, it should load the book's list from the local database, however it should only show the books whos' chapters are downloaded and available offline can be played.
+- When the server becomes reachable, it should update the books list, as now all the books can be played from local cache or online from the server.
+- When the network is switched, the app should trigger checking whether the server is still reachable or not, if not reachable, it should update the UI to only show offline available ready to play books.
+
+## General Guidelines and Standards
+
+- **Colors**: Must be referenced from `Color.kt` / `Theme.kt`. Do not use raw hex values (e.g., `0xFF...`) in Composables.
+- **Dimensions**: Must use `Spacing.kt` (e.g., `Spacing.md`, `Spacing.lg`) for padding, margins, and layout dimensions.
+- **Design System**: Adhere effectively to the spacing system and color palette defined in the project.
+- **ABSOLUTELY NO** hardcoded user-facing strings in UI code. All strings must be extracted to `strings.xml` and accessed via `stringResource`.
+- Use `associateBy` or proper indexing for collection lookups (O(1)) instead of nested loops (O(N^2)) when synchronizing data.
+- Avoid expensive operations on the main thread.
+- No code duplication, keep the code clean and easy to maintain.
diff --git a/.github/workflows/auto-version-bump.yml b/.github/workflows/auto-version-bump.yml
new file mode 100644
index 000000000..e22ce5cce
--- /dev/null
+++ b/.github/workflows/auto-version-bump.yml
@@ -0,0 +1,54 @@
+name: Auto Version Bump
+
+on:
+ push:
+ branches:
+ - main
+ paths-ignore:
+ - 'gradle.properties'
+ - '.github/workflows/**'
+
+jobs:
+ bump-version:
+ runs-on: ubuntu-latest
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Get Current Version
+ id: get_version
+ run: |
+ VERSION=$(grep 'appVersionName=' gradle.properties | cut -d'=' -f2)
+ echo "current_version=$VERSION" >> $GITHUB_OUTPUT
+
+ - name: Determine Bump Type
+ id: bump_type
+ uses: anothrNick/github-tag-action@1.67.0
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ WITH_V: false
+ DRY_RUN: true
+ DEFAULT_BUMP: patch
+
+ - name: Bump Version in Files
+ id: bump
+ run: |
+ BUMP_TYPE="${{ steps.bump_type.outputs.part }}"
+ if [ -z "$BUMP_TYPE" ]; then BUMP_TYPE="patch"; fi
+ NEW_VERSION=$(python3 scripts/bump_version.py ${{ steps.get_version.outputs.current_version }} $BUMP_TYPE)
+ echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
+
+ - name: Commit and Push Changes
+ run: |
+ NEW_TAG="v${{ steps.bump.outputs.new_version }}"
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git add gradle.properties
+ git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} [skip ci]"
+ git tag $NEW_TAG
+ git push origin main
+ git push origin $NEW_TAG
diff --git a/.github/workflows/build_app.yml b/.github/workflows/build_app.yml
index 49c83e991..01cfa3dd8 100644
--- a/.github/workflows/build_app.yml
+++ b/.github/workflows/build_app.yml
@@ -1,4 +1,4 @@
-name: Build Lissen App
+name: Build Kahani App
env:
# The name of the main module repository
@@ -6,15 +6,14 @@ env:
on:
push:
- branches: [ "main" ]
+ branches: ['main']
pull_request:
- branches: [ "main" ]
+ branches: ['main']
workflow_dispatch:
jobs:
build:
-
runs-on: ubuntu-latest
steps:
@@ -37,6 +36,9 @@ jobs:
- name: Change wrapper permissions
run: chmod +x ./gradlew
+ - name: Create Schemas Directory
+ run: mkdir -p app/schemas
+
# Run Build Project
- name: Build gradle project
- run: ./gradlew build -Proom.schemaLocation=$GITHUB_WORKSPACE/app/schemas
+ run: ./gradlew build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..75868b2df
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,53 @@
+name: Build and Release Kahani
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ release:
+ name: Build Signed APK
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '21'
+ cache: 'gradle'
+
+ - name: Create Schemas Directory
+ run: mkdir -p app/schemas
+
+ - name: Decode Keystore
+ run: |
+ echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 --decode > app/kahani-release.jks
+
+ - name: Build Release APK
+ run: ./gradlew assembleRelease
+ env:
+ RELEASE_STORE_FILE: kahani-release.jks
+ RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_PASSWORD }}
+ RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
+ RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ files: app/build/outputs/apk/release/app-release.apk
+ tag_name: ${{ github.ref_name }}
+ name: Release ${{ github.ref_name }}
+ body: |
+ Automated Release for Kahani.
+ Commit: ${{ github.sha }}
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 35432b3e3..51ec856e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,4 +34,4 @@ google-services.json
*.hprof
# Schemas
-app/schemas
+.DS_Store
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 000000000..3c0c329e7
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,21 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "gradle",
+ "id": "${workspaceFolder}app:installDebugapp",
+ "script": "app:installDebug",
+ "description": "Installs the Debug build.",
+ "group": "install",
+ "project": "app",
+ "buildFile": "${workspaceFolder}/app/build.gradle.kts",
+ "rootProject": "Lissen",
+ "projectFolder": "${workspaceFolder}",
+ "workspaceFolder": "${workspaceFolder}",
+ "args": "",
+ "javaDebug": false,
+ "problemMatcher": ["$gradle"],
+ "label": "gradle: app:installDebug"
+ }
+ ]
+}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4a054f5d0..90a71533e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -50,11 +50,11 @@ android {
defaultConfig {
val commitHash = gitCommitHash()
- applicationId = "org.grakovne.lissen"
+ applicationId = "org.surjit.kahani"
minSdk = 28
targetSdk = 36
- versionCode = 10800
- versionName = "1.8.0-$commitHash"
+ versionCode = project.property("appVersionCode").toString().toInt()
+ versionName = project.property("appVersionName").toString()
buildConfigField("String", "GIT_HASH", "\"$commitHash\"")
@@ -66,27 +66,36 @@ android {
buildConfigField("String", "ACRA_REPORT_LOGIN", "\"$acraReportLogin\"")
buildConfigField("String", "ACRA_REPORT_PASSWORD", "\"$acraReportPassword\"")
- if (project.hasProperty("RELEASE_STORE_FILE")) {
- signingConfigs {
- create("release") {
- storeFile = file(project.property("RELEASE_STORE_FILE")!!)
- storePassword = project.property("RELEASE_STORE_PASSWORD") as String?
- keyAlias = project.property("RELEASE_KEY_ALIAS") as String?
- keyPassword = project.property("RELEASE_KEY_PASSWORD") as String?
- enableV1Signing = true
- enableV2Signing = true
+ signingConfigs {
+ create("release") {
+ val envKeyStore = System.getenv("RELEASE_STORE_FILE")
+ val propKeyStore = localProperties.getProperty("RELEASE_STORE_FILE")
+
+ storeFile = when {
+ envKeyStore != null -> file(envKeyStore)
+ propKeyStore != null -> file(propKeyStore)
+ else -> null
}
+
+ storePassword = System.getenv("RELEASE_STORE_PASSWORD") ?: localProperties.getProperty("RELEASE_STORE_PASSWORD")
+ keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: localProperties.getProperty("RELEASE_KEY_ALIAS")
+ keyPassword = System.getenv("RELEASE_KEY_PASSWORD") ?: localProperties.getProperty("RELEASE_KEY_PASSWORD")
+
+ enableV1Signing = true
+ enableV2Signing = true
}
}
}
-
buildTypes {
release {
- if (project.hasProperty("RELEASE_STORE_FILE")) {
- signingConfig = signingConfigs.getByName("release")
+ val releaseSigningConfig = signingConfigs.getByName("release")
+
+ if (releaseSigningConfig.storeFile?.exists() == true) {
+ signingConfig = releaseSigningConfig
}
- isMinifyEnabled = false
+
+ isMinifyEnabled = true
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
diff --git a/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/14.json b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/14.json
new file mode 100644
index 000000000..646b2d2b0
--- /dev/null
+++ b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/14.json
@@ -0,0 +1,346 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 14,
+ "identityHash": "c82cbd6cc4b5d043bc4c63ccd1323a2e",
+ "entities": [
+ {
+ "tableName": "detailed_books",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `author` TEXT, `narrator` TEXT, `year` TEXT, `abstract` TEXT, `publisher` TEXT, `duration` INTEGER NOT NULL, `libraryId` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subtitle",
+ "columnName": "subtitle",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "narrator",
+ "columnName": "narrator",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "abstract",
+ "columnName": "abstract",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "publisher",
+ "columnName": "publisher",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "libraryId",
+ "columnName": "libraryId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seriesJson",
+ "columnName": "seriesJson",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seriesNames",
+ "columnName": "seriesNames",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "book_files",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookFileId` TEXT NOT NULL, `name` TEXT NOT NULL, `duration` REAL NOT NULL, `mimeType` TEXT NOT NULL, `bookId` TEXT NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookFileId",
+ "columnName": "bookFileId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookId",
+ "columnName": "bookId",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_book_files_bookId",
+ "unique": false,
+ "columnNames": [
+ "bookId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_book_files_bookId` ON `${TABLE_NAME}` (`bookId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "detailed_books",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "bookId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "book_chapters",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookChapterId` TEXT NOT NULL, `duration` REAL NOT NULL, `start` REAL NOT NULL, `end` REAL NOT NULL, `title` TEXT NOT NULL, `bookId` TEXT NOT NULL, `isCached` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookChapterId",
+ "columnName": "bookChapterId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "start",
+ "columnName": "start",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "end",
+ "columnName": "end",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookId",
+ "columnName": "bookId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isCached",
+ "columnName": "isCached",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_book_chapters_bookId",
+ "unique": false,
+ "columnNames": [
+ "bookId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_book_chapters_bookId` ON `${TABLE_NAME}` (`bookId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "detailed_books",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "bookId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "media_progress",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `currentTime` REAL NOT NULL, `isFinished` INTEGER NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`bookId`), FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "bookId",
+ "columnName": "bookId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentTime",
+ "columnName": "currentTime",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFinished",
+ "columnName": "isFinished",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "lastUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "bookId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_media_progress_bookId",
+ "unique": false,
+ "columnNames": [
+ "bookId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_media_progress_bookId` ON `${TABLE_NAME}` (`bookId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "detailed_books",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "bookId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "libraries",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "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, 'c82cbd6cc4b5d043bc4c63ccd1323a2e')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
index 01c6165d0..c6ac7abba 100644
--- a/app/src/debug/res/values/strings.xml
+++ b/app/src/debug/res/values/strings.xml
@@ -1,3 +1,3 @@
- Lissen (DEBUG)
-
\ No newline at end of file
+ Kahani (DEBUG)
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 50e9cf5c0..e924cf831 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -26,7 +26,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:largeHeap="true"
- android:theme="@style/Theme.Lissen"
+ android:theme="@style/Theme.Kahani"
tools:ignore="DiscouragedApi,UnusedAttribute"
android:manageSpaceActivity="org.grakovne.lissen.content.LissenDataManagementActivity"
tools:targetApi="36">
@@ -41,7 +41,7 @@
android:name=".ui.activity.AppActivity"
android:exported="true"
android:launchMode="singleTop"
- android:theme="@style/Theme.Lissen"
+ android:theme="@style/Theme.Kahani"
tools:ignore="LockedOrientationActivity">
@@ -68,7 +68,7 @@
diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt
index 2108e8dfb..9d085f80c 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt
@@ -10,7 +10,10 @@ import javax.inject.Singleton
class LibraryPageResponseConverter
@Inject
constructor() {
- fun apply(response: LibraryItemsResponse): PagedItems =
+ fun apply(
+ response: LibraryItemsResponse,
+ libraryId: String,
+ ): PagedItems =
response
.results
.mapNotNull {
@@ -22,6 +25,8 @@ class LibraryPageResponseConverter
series = it.media.metadata.seriesName,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
+ duration = 0.0,
+ libraryId = libraryId,
)
}.let {
PagedItems(
diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt
index c2ebf5d32..462c885ff 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt
@@ -71,7 +71,7 @@ class LibraryAudiobookshelfChannel
pageNumber = pageNumber,
sort = option,
direction = direction,
- ).map { libraryPageResponseConverter.apply(it) }
+ ).map { libraryPageResponseConverter.apply(it, libraryId) }
}
override suspend fun searchBooks(
@@ -87,7 +87,7 @@ class LibraryAudiobookshelfChannel
searchResult
.map { it.book }
.map { it.map { response -> response.libraryItem } }
- .map { librarySearchItemsConverter.apply(it) }
+ .map { librarySearchItemsConverter.apply(it, libraryId) }
}
val byAuthor =
@@ -106,7 +106,7 @@ class LibraryAudiobookshelfChannel
onFailure = { emptyList() },
)
}
- }.map { librarySearchItemsConverter.apply(it) }
+ }.map { librarySearchItemsConverter.apply(it, libraryId) }
}
val bySeries: Deferred>> =
@@ -125,7 +125,7 @@ class LibraryAudiobookshelfChannel
)
}
}.map { result -> result.map { it.libraryItem } }
- .map { result -> result.let { librarySearchItemsConverter.apply(it) } }
+ .map { result -> result.let { librarySearchItemsConverter.apply(it, libraryId) } }
}
mergeBooks(byTitle, byAuthor, bySeries)
diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt
index c0ba657a9..048734958 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt
@@ -8,6 +8,9 @@ import org.grakovne.lissen.lib.domain.BookSeries
import org.grakovne.lissen.lib.domain.DetailedItem
import org.grakovne.lissen.lib.domain.MediaProgress
import org.grakovne.lissen.lib.domain.PlayingChapter
+import java.time.LocalDate
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
@@ -88,7 +91,7 @@ class BookResponseConverter
chapters = maybeChapters ?: filesAsChapters(),
libraryId = item.libraryId,
localProvided = false,
- year = item.media.metadata.publishedYear,
+ year = extractYear(item.media.metadata.publishedYear),
abstract = item.media.metadata.description,
publisher = item.media.metadata.publisher,
series =
@@ -115,4 +118,28 @@ class BookResponseConverter
},
)
}
+
+ private fun extractYear(rawYear: String?): String? {
+ if (rawYear.isNullOrBlank()) {
+ return null
+ }
+
+ // 1. If it's explicitly 4 digits, assume it's a year
+ if (rawYear.matches(Regex("^\\d{4}$"))) {
+ return rawYear
+ }
+
+ return try {
+ // 2. Try parsing as ZonedDateTime (ISO 8601 with timezone, e.g. 2010-10-07T07:13:01Z)
+ ZonedDateTime.parse(rawYear).year.toString()
+ } catch (e: Exception) {
+ try {
+ // 3. Try parsing as LocalDate (yyyy-MM-dd)
+ LocalDate.parse(rawYear).year.toString()
+ } catch (e: Exception) {
+ // 4. Fallback: If it starts with 4 digits, take them
+ Regex("^(\\d{4})").find(rawYear)?.groupValues?.get(1) ?: rawYear
+ }
+ }
+ }
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt
index 03ada962a..8e855f95b 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt
@@ -9,17 +9,21 @@ import javax.inject.Singleton
class LibrarySearchItemsConverter
@Inject
constructor() {
- fun apply(response: List) =
- response
- .mapNotNull {
- val title = it.media.metadata.title ?: return@mapNotNull null
+ fun apply(
+ response: List,
+ libraryId: String,
+ ) = response
+ .mapNotNull {
+ val title = it.media.metadata.title ?: return@mapNotNull null
- Book(
- id = it.id,
- title = title,
- series = it.media.metadata.seriesName,
- subtitle = it.media.metadata.subtitle,
- author = it.media.metadata.authorName,
- )
- }
+ Book(
+ id = it.id,
+ title = title,
+ series = it.media.metadata.seriesName,
+ subtitle = it.media.metadata.subtitle,
+ author = it.media.metadata.authorName,
+ duration = 0.0,
+ libraryId = libraryId,
+ )
+ }
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt
index e0be0e9d2..65b19f45e 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt
@@ -68,7 +68,7 @@ class PodcastAudiobookshelfChannel
pageNumber = pageNumber,
sort = option,
direction = direction,
- ).map { podcastPageResponseConverter.apply(it) }
+ ).map { podcastPageResponseConverter.apply(it, libraryId) }
}
override suspend fun searchBooks(
@@ -83,7 +83,7 @@ class PodcastAudiobookshelfChannel
.searchPodcasts(libraryId, query, limit)
.map { it.podcast }
.map { it.map { response -> response.libraryItem } }
- .map { podcastSearchItemsConverter.apply(it) }
+ .map { podcastSearchItemsConverter.apply(it, libraryId) }
}
byTitle.await()
diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt
index 4fe16f108..465e47c54 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt
@@ -10,7 +10,10 @@ import javax.inject.Singleton
class PodcastPageResponseConverter
@Inject
constructor() {
- fun apply(response: PodcastItemsResponse): PagedItems =
+ fun apply(
+ response: PodcastItemsResponse,
+ libraryId: String,
+ ): PagedItems =
response
.results
.mapNotNull {
@@ -22,6 +25,9 @@ class PodcastPageResponseConverter
subtitle = null,
series = null,
author = it.media.metadata.author,
+ // Duration is unavailable from the API
+ duration = 0.0,
+ libraryId = libraryId,
)
}.let {
PagedItems(
diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt
index 13310b7be..80ad49207 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt
@@ -9,7 +9,10 @@ import javax.inject.Singleton
class PodcastSearchItemsConverter
@Inject
constructor() {
- fun apply(response: List): List {
+ fun apply(
+ response: List,
+ libraryId: String,
+ ): List {
return response
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null
@@ -20,6 +23,8 @@ class PodcastSearchItemsConverter
subtitle = null,
series = null,
author = it.media.metadata.author,
+ duration = 0.0,
+ libraryId = libraryId,
)
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt
index d2a1727ef..c56cc90d5 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt
@@ -7,7 +7,17 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
import org.grakovne.lissen.lib.domain.NetworkType
+import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -17,29 +27,97 @@ class NetworkService
@Inject
constructor(
@ApplicationContext private val context: Context,
+ private val preferences: LissenSharedPreferences,
) : RunningComponent {
private val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
private var cachedNetworkHandle: Long? = null
private var cachedSsid: String? = null
+ private val _networkStatus = MutableStateFlow(false)
+ val networkStatus: StateFlow = _networkStatus
+
+ private val _isServerAvailable = MutableStateFlow(false)
+ val isServerAvailable: StateFlow = _isServerAvailable
+
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
override fun onCreate() {
val networkRequest =
NetworkRequest
.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+ .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()
val networkCallback =
object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ checkServerAvailability()
+ }
+
override fun onLost(network: Network) {
if (cachedNetworkHandle == network.getNetworkHandle()) {
cachedSsid = null
}
+ checkServerAvailability()
+ }
+
+ override fun onCapabilitiesChanged(
+ network: Network,
+ networkCapabilities: NetworkCapabilities,
+ ) {
+ checkServerAvailability()
}
}
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
+ checkServerAvailability()
+
+ scope.launch {
+ preferences.hostFlow.collect {
+ checkServerAvailability()
+ }
+ }
+ }
+
+ private var checkJob: Job? = null
+
+ private fun checkServerAvailability() {
+ checkJob?.cancel()
+ checkJob =
+ scope.launch {
+ delay(500)
+ val isConnectedToInternet = isNetworkAvailable()
+ _networkStatus.emit(isConnectedToInternet)
+
+ if (!isConnectedToInternet) {
+ _isServerAvailable.emit(false)
+ return@launch
+ }
+
+ val hostUrl = preferences.getHost()
+ if (hostUrl.isNullOrBlank()) {
+ _isServerAvailable.emit(false)
+ return@launch
+ }
+
+ try {
+ val url = java.net.URL(hostUrl)
+ val port = if (url.port == -1) url.defaultPort else url.port
+ val address = java.net.InetSocketAddress(url.host, port)
+
+ java.net.Socket().use { socket ->
+ socket.connect(address, 2000)
+ }
+
+ _isServerAvailable.emit(true)
+ } catch (e: Exception) {
+ Timber.e(e, "Server reachability check failed for $hostUrl")
+ _isServerAvailable.emit(false)
+ }
+ }
}
fun isNetworkAvailable(): Boolean {
@@ -70,7 +148,8 @@ class NetworkService
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return null
- val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
+ val wifiManager =
+ context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
val wifiInfo = wifiManager.connectionInfo
val ssid = wifiInfo.ssid
@@ -85,4 +164,8 @@ class NetworkService
cachedNetworkHandle = network.networkHandle
return cachedSsid
}
+
+ override fun onDestroy() {
+ scope.cancel()
+ }
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/RunningComponent.kt b/app/src/main/kotlin/org/grakovne/lissen/common/RunningComponent.kt
index 487097d7f..a8433556f 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/common/RunningComponent.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/common/RunningComponent.kt
@@ -2,4 +2,6 @@ package org.grakovne.lissen.common
interface RunningComponent {
fun onCreate()
+
+ fun onDestroy() {}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt
new file mode 100644
index 000000000..a3d7359cc
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt
@@ -0,0 +1,96 @@
+package org.grakovne.lissen.content
+
+import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider
+import org.grakovne.lissen.channel.common.ChannelAuthService
+import org.grakovne.lissen.channel.common.OperationError
+import org.grakovne.lissen.channel.common.OperationResult
+import org.grakovne.lissen.lib.domain.Library
+import org.grakovne.lissen.lib.domain.LibraryType
+import org.grakovne.lissen.lib.domain.UserAccount
+import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthRepository
+ @Inject
+ constructor(
+ private val preferences: LissenSharedPreferences,
+ private val audiobookshelfChannelProvider: AudiobookshelfChannelProvider,
+ private val bookRepository: BookRepository,
+ ) {
+ suspend fun authorize(
+ host: String,
+ username: String,
+ password: String,
+ ): OperationResult {
+ Timber.d("Authorizing for host: $host")
+ return provideAuthService().authorize(host, username, password) { onPostLogin(host, it) }
+ }
+
+ suspend fun startOAuth(
+ host: String,
+ onSuccess: () -> Unit,
+ onFailure: (OperationError) -> Unit,
+ ) {
+ Timber.d("Starting OAuth for $host")
+
+ return provideAuthService()
+ .startOAuth(
+ host = host,
+ onSuccess = onSuccess,
+ onFailure = { onFailure(it) },
+ )
+ }
+
+ suspend fun onPostLogin(
+ host: String,
+ account: UserAccount,
+ ) {
+ provideAuthService()
+ .persistCredentials(
+ host = host,
+ username = account.username,
+ token = account.token,
+ accessToken = account.accessToken,
+ refreshToken = account.refreshToken,
+ )
+
+ // Trigger library fetch
+ bookRepository
+ .fetchLibraries()
+ .fold(
+ onSuccess = {
+ val preferredLibrary =
+ it
+ .find { item -> item.id == account.preferredLibraryId }
+ ?: it.firstOrNull()
+
+ preferredLibrary
+ ?.let { library ->
+ preferences.savePreferredLibrary(
+ Library(
+ id = library.id,
+ title = library.title,
+ type = library.type,
+ ),
+ )
+ }
+ },
+ onFailure = {
+ account
+ .preferredLibraryId
+ ?.let { library ->
+ Library(
+ id = library,
+ title = "Default Library",
+ type = LibraryType.LIBRARY,
+ )
+ }?.let { preferences.savePreferredLibrary(it) }
+ },
+ )
+ }
+
+ private fun provideAuthService(): ChannelAuthService = audiobookshelfChannelProvider.provideChannelAuth()
+ }
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt
new file mode 100644
index 000000000..792b24928
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt
@@ -0,0 +1,427 @@
+package org.grakovne.lissen.content
+
+import android.net.Uri
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider
+import org.grakovne.lissen.channel.common.MediaChannel
+import org.grakovne.lissen.channel.common.OperationError
+import org.grakovne.lissen.channel.common.OperationResult
+import org.grakovne.lissen.common.NetworkService
+import org.grakovne.lissen.content.cache.persistent.LocalCacheRepository
+import org.grakovne.lissen.content.cache.temporary.CachedCoverProvider
+import org.grakovne.lissen.lib.domain.Book
+import org.grakovne.lissen.lib.domain.DetailedItem
+import org.grakovne.lissen.lib.domain.Library
+import org.grakovne.lissen.lib.domain.PagedItems
+import org.grakovne.lissen.lib.domain.PlaybackProgress
+import org.grakovne.lissen.lib.domain.PlaybackSession
+import org.grakovne.lissen.lib.domain.RecentBook
+import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BookRepository
+ @Inject
+ constructor(
+ private val preferences: LissenSharedPreferences,
+ private val audiobookshelfChannelProvider: AudiobookshelfChannelProvider,
+ private val localCacheRepository: LocalCacheRepository,
+ private val cachedCoverProvider: CachedCoverProvider,
+ private val networkService: NetworkService,
+ ) {
+ fun provideFileUri(
+ libraryItemId: String,
+ chapterId: String,
+ ): OperationResult {
+ Timber.d("Fetching File $libraryItemId and $chapterId URI")
+
+ localCacheRepository
+ .provideFileUri(libraryItemId, chapterId)
+ ?.let {
+ Timber.d("Providing LOCAL URI for $libraryItemId / $chapterId: $it")
+ return OperationResult.Success(it)
+ }
+
+ Timber.d("Local URI miss for $libraryItemId / $chapterId. Falling back to REMOTE.")
+
+ return try {
+ val uri = providePreferredChannel().provideFileUri(libraryItemId, chapterId)
+
+ if (uri == null) {
+ OperationResult.Error(OperationError.InternalError, "Remote URI is null")
+ } else {
+ Timber.d("Providing REMOTE URI for $libraryItemId / $chapterId: $uri")
+ OperationResult.Success(uri)
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to provide file URI for $libraryItemId and $chapterId")
+ OperationResult.Error(OperationError.InternalError, e.message ?: "Unknown error occurred")
+ }
+ }
+
+ suspend fun syncProgress(
+ sessionId: String,
+ itemId: String,
+ progress: PlaybackProgress,
+ ): OperationResult {
+ Timber.d("Syncing Progress for $itemId. $progress")
+
+ localCacheRepository.syncProgress(itemId, progress)
+
+ val channelSyncResult =
+ providePreferredChannel()
+ .syncProgress(sessionId, progress)
+
+ return when (preferences.isForceCache()) {
+ true -> OperationResult.Success(Unit)
+ false -> channelSyncResult
+ }
+ }
+
+ suspend fun syncLocalProgress(
+ itemId: String,
+ progress: PlaybackProgress,
+ ): OperationResult {
+ Timber.d("Syncing LOCAL Progress only for $itemId. $progress")
+ localCacheRepository.syncProgress(itemId, progress)
+ return OperationResult.Success(Unit)
+ }
+
+ suspend fun fetchBookCover(
+ bookId: String,
+ width: Int? = null,
+ ): OperationResult {
+ val localResult = localCacheRepository.fetchBookCover(bookId)
+ if (localResult is OperationResult.Success) {
+ return localResult
+ }
+
+ return cachedCoverProvider.provideCover(
+ channel = providePreferredChannel(),
+ itemId = bookId,
+ width = width,
+ )
+ }
+
+ suspend fun searchBooks(
+ libraryId: String,
+ query: String,
+ limit: Int,
+ ): OperationResult> {
+ Timber.d("Searching books with query $query of library: $libraryId")
+
+ val localResult = localCacheRepository.searchBooks(libraryId = libraryId, query = query)
+ if (localResult is OperationResult.Success && localResult.data.isNotEmpty()) {
+ return localResult
+ }
+
+ return providePreferredChannel()
+ .searchBooks(
+ libraryId = libraryId,
+ query = query,
+ limit = limit,
+ )
+ }
+
+ suspend fun fetchBooks(
+ libraryId: String,
+ pageSize: Int,
+ pageNumber: Int,
+ downloadedOnly: Boolean = false,
+ ): OperationResult> {
+ Timber.d("Fetching page $pageNumber of library: $libraryId. Downloaded only: $downloadedOnly")
+
+ val localResult =
+ localCacheRepository.fetchBooks(
+ libraryId = libraryId,
+ pageSize = pageSize,
+ pageNumber = pageNumber,
+ downloadedOnly = downloadedOnly,
+ )
+
+ if (downloadedOnly) {
+ return localResult
+ }
+
+ val localItems =
+ localResult.fold(
+ onSuccess = { it.items },
+ onFailure = { emptyList() },
+ )
+
+ if (localItems.isEmpty() || localItems.size < pageSize) {
+ Timber.d("Local cache miss (or partial) for page $pageNumber. Fetching from remote.")
+ return providePreferredChannel()
+ .fetchBooks(
+ libraryId = libraryId,
+ pageSize = pageSize,
+ pageNumber = pageNumber,
+ ).also {
+ it.foldAsync(
+ onSuccess = { result -> localCacheRepository.cacheBooks(result.items) },
+ onFailure = {},
+ )
+ }
+ }
+
+ return localResult
+ }
+
+ suspend fun syncLibraryPage(
+ libraryId: String,
+ pageSize: Int,
+ pageNumber: Int,
+ ): OperationResult =
+ providePreferredChannel()
+ .fetchBooks(libraryId, pageSize, pageNumber)
+ .map { localCacheRepository.cacheBooks(it.items) }
+
+ suspend fun fetchLibraries(): OperationResult> {
+ Timber.d("Fetching List of libraries")
+
+ val localResult = localCacheRepository.fetchLibraries()
+ if (localResult is OperationResult.Success && localResult.data.isNotEmpty()) {
+ return localResult
+ }
+
+ return providePreferredChannel()
+ .fetchLibraries()
+ .also {
+ it.foldAsync(
+ onSuccess = { libraries -> localCacheRepository.updateLibraries(libraries) },
+ onFailure = {},
+ )
+ }
+ }
+
+ suspend fun startPlayback(
+ itemId: String,
+ chapterId: String,
+ supportedMimeTypes: List,
+ deviceId: String,
+ ): OperationResult {
+ Timber.d("Starting Playback for $itemId. $supportedMimeTypes are supported")
+
+ return providePreferredChannel().startPlayback(
+ bookId = itemId,
+ episodeId = chapterId,
+ supportedMimeTypes = supportedMimeTypes,
+ deviceId = deviceId,
+ )
+ }
+
+ suspend fun fetchRecentListenedBooks(libraryId: String): OperationResult> {
+ Timber.d("Fetching Recent books of library $libraryId")
+
+ val isOffline = !networkService.isServerAvailable.value || preferences.isForceCache()
+
+ val localResult =
+ localCacheRepository.fetchRecentListenedBooks(
+ libraryId = libraryId,
+ downloadedOnly = isOffline,
+ )
+
+ if (isOffline) {
+ return localResult
+ }
+
+ if (localResult is OperationResult.Success && localResult.data.isNotEmpty()) {
+ return localResult
+ }
+
+ return providePreferredChannel()
+ .fetchRecentListenedBooks(libraryId)
+ .map { items -> syncFromLocalProgress(libraryId = libraryId, detailedItems = items) }
+ }
+
+ fun fetchRecentListenedBooksFlow(libraryId: String): Flow> {
+ val isOffline = !networkService.isServerAvailable.value || preferences.isForceCache()
+
+ return localCacheRepository.fetchRecentListenedBooksFlow(
+ libraryId = libraryId,
+ downloadedOnly = isOffline,
+ )
+ }
+
+ suspend fun fetchBook(bookId: String): OperationResult {
+ Timber.d("Fetching Detailed book info for $bookId")
+
+ val localResult = localCacheRepository.fetchBook(bookId)
+ val isDetailed =
+ localResult
+ ?.let { it.chapters.isNotEmpty() || it.files.isNotEmpty() }
+ ?: false
+
+ if (localResult != null && isDetailed) {
+ return OperationResult.Success(makeAvailableIfOnline(localResult))
+ }
+
+ return providePreferredChannel()
+ .fetchBook(bookId)
+ .map { syncFromLocalProgress(it) }
+ .also {
+ it.foldAsync(
+ onSuccess = { book -> localCacheRepository.cacheBookMetadata(book) },
+ onFailure = {},
+ )
+ }.map { makeAvailableIfOnline(it) }
+ }
+
+ private fun makeAvailableIfOnline(book: DetailedItem): DetailedItem {
+ if (!networkService.isNetworkAvailable()) {
+ return book
+ }
+
+ val isAllCached = book.chapters.all { it.available }
+ if (isAllCached) {
+ return book
+ }
+
+ return book.copy(
+ chapters = book.chapters.map { it.copy(available = true) },
+ )
+ }
+
+ fun fetchBookFlow(bookId: String): Flow =
+ localCacheRepository
+ .fetchBookFlow(bookId)
+ .combine(networkService.networkStatus) { book: DetailedItem?, isOnline: Boolean ->
+ if (book == null) return@combine null
+
+ val isAllCached = book.chapters.all { it.available }
+ if (!isOnline || isAllCached) return@combine book
+
+ book.copy(
+ chapters = book.chapters.map { it.copy(available = true) },
+ )
+ }
+
+ suspend fun syncRepositories() {
+ val libraryId = preferences.getPreferredLibrary()?.id ?: return
+ val remoteRecents = providePreferredChannel().fetchRecentListenedBooks(libraryId).getOrNull() ?: emptyList()
+ val localRecents =
+ localCacheRepository
+ .fetchRecentListenedBooks(
+ libraryId = libraryId,
+ downloadedOnly = false,
+ ).getOrNull() ?: emptyList()
+
+ val remoteMap = remoteRecents.associateBy { it.id }
+ val localMap = localRecents.associateBy { it.id }
+ val allIds = (remoteMap.keys + localMap.keys).toSet()
+
+ for (id in allIds) {
+ val remote = remoteMap[id]
+ val local = localMap[id]
+
+ val remoteTime = remote?.listenedLastUpdate ?: 0L
+ val localTime = local?.listenedLastUpdate ?: 0L
+
+ when {
+ remoteTime > localTime -> {
+ providePreferredChannel().fetchBook(id).foldAsync(
+ onSuccess = { localCacheRepository.cacheBookMetadata(it) },
+ onFailure = {},
+ )
+ }
+
+ localTime > remoteTime -> {
+ val book = localCacheRepository.fetchBook(id) ?: continue
+ val progress = book.progress ?: continue
+
+ val session =
+ providePreferredChannel()
+ .startPlayback(
+ bookId = id,
+ episodeId = book.chapters.firstOrNull()?.id ?: continue,
+ supportedMimeTypes = emptyList(),
+ deviceId = preferences.getDeviceId(),
+ ).getOrNull() ?: continue
+
+ providePreferredChannel().syncProgress(
+ sessionId = session.sessionId,
+ progress =
+ PlaybackProgress(
+ currentTotalTime = progress.currentTime,
+ currentChapterTime = 0.0, // Server will recalculate based on total time
+ ),
+ )
+ }
+ }
+ }
+ }
+
+ private fun OperationResult.getOrNull(): T? =
+ when (this) {
+ is OperationResult.Success -> data
+ is OperationResult.Error -> null
+ }
+
+ private suspend fun syncFromLocalProgress(
+ libraryId: String,
+ detailedItems: List,
+ ): List {
+ val localRecentlyBooks =
+ localCacheRepository
+ .fetchRecentListenedBooks(
+ libraryId = libraryId,
+ downloadedOnly = false,
+ ).fold(
+ onSuccess = { it },
+ onFailure = { return@fold detailedItems },
+ )
+
+ val localMap = localRecentlyBooks.associateBy { it.id }
+
+ val syncedRecentlyBooks =
+ detailedItems
+ .mapNotNull { item -> localMap[item.id]?.let { item to it } }
+ .map { (remote, local) ->
+ val localTimestamp = local.listenedLastUpdate ?: return@map remote
+ val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote
+
+ when (remoteTimestamp > localTimestamp) {
+ true -> remote
+ false -> local
+ }
+ }
+
+ val syncedMap = syncedRecentlyBooks.associateBy { it.id }
+
+ return detailedItems
+ .map { item ->
+ syncedMap
+ .get(item.id)
+ ?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) }
+ ?: item
+ }
+ }
+
+ private suspend fun syncFromLocalProgress(detailedItem: DetailedItem): DetailedItem {
+ val cachedBook = localCacheRepository.fetchBook(detailedItem.id) ?: return detailedItem
+
+ val cachedProgress = cachedBook.progress ?: return detailedItem
+ val channelProgress = detailedItem.progress
+
+ if (channelProgress == null) return detailedItem.copy(progress = cachedProgress)
+
+ val updatedProgress =
+ if (cachedProgress.lastUpdate > channelProgress.lastUpdate) {
+ cachedProgress
+ } else {
+ channelProgress
+ }
+
+ return detailedItem.copy(progress = updatedProgress)
+ }
+
+ fun fetchConnectionHost() = providePreferredChannel().fetchConnectionHost()
+
+ suspend fun fetchConnectionInfo() = providePreferredChannel().fetchConnectionInfo()
+
+ fun providePreferredChannel(): MediaChannel = audiobookshelfChannelProvider.provideMediaChannel()
+ }
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt
index b24e693fa..00307d17d 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt
@@ -1,11 +1,14 @@
package org.grakovne.lissen.content
import android.net.Uri
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider
import org.grakovne.lissen.channel.common.ChannelAuthService
import org.grakovne.lissen.channel.common.MediaChannel
import org.grakovne.lissen.channel.common.OperationError
import org.grakovne.lissen.channel.common.OperationResult
+import org.grakovne.lissen.common.NetworkService
import org.grakovne.lissen.content.cache.persistent.LocalCacheRepository
import org.grakovne.lissen.content.cache.temporary.CachedCoverProvider
import org.grakovne.lissen.lib.domain.Book
@@ -31,6 +34,7 @@ class LissenMediaProvider
private val audiobookshelfChannelProvider: AudiobookshelfChannelProvider, // the only one channel which may be extended
private val localCacheRepository: LocalCacheRepository,
private val cachedCoverProvider: CachedCoverProvider,
+ private val networkService: NetworkService,
) {
fun provideFileUri(
libraryItemId: String,
@@ -38,20 +42,21 @@ class LissenMediaProvider
): OperationResult {
Timber.d("Fetching File $libraryItemId and $chapterId URI")
- return when (preferences.isForceCache()) {
- true ->
- localCacheRepository
- .provideFileUri(libraryItemId, chapterId)
- ?.let { OperationResult.Success(it) }
- ?: OperationResult.Error(OperationError.InternalError)
-
- false ->
- localCacheRepository
- .provideFileUri(libraryItemId, chapterId)
- ?.let { OperationResult.Success(it) }
- ?: providePreferredChannel()
- .provideFileUri(libraryItemId, chapterId)
- .let { OperationResult.Success(it) }
+ localCacheRepository
+ .provideFileUri(libraryItemId, chapterId)
+ ?.let {
+ Timber.d("Providing LOCAL URI for $libraryItemId / $chapterId: $it")
+ return OperationResult.Success(it)
+ }
+
+ Timber.d("Local URI miss for $libraryItemId / $chapterId. Falling back to REMOTE.")
+ return try {
+ val uri = providePreferredChannel().provideFileUri(libraryItemId, chapterId)
+ Timber.d("Providing REMOTE URI for $libraryItemId / $chapterId: $uri")
+ OperationResult.Success(uri)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to provide file URI for $libraryItemId and $chapterId")
+ OperationResult.Error(OperationError.InternalError, e.message)
}
}
@@ -74,20 +79,25 @@ class LissenMediaProvider
}
}
+ suspend fun syncLocalProgress(
+ itemId: String,
+ progress: PlaybackProgress,
+ ): OperationResult = localCacheRepository.syncProgress(itemId, progress)
+
suspend fun fetchBookCover(
bookId: String,
width: Int? = null,
): OperationResult {
- Timber.d("Fetching Cover stream for $bookId")
- return when (preferences.isForceCache()) {
- true -> localCacheRepository.fetchBookCover(bookId)
- false ->
- cachedCoverProvider.provideCover(
- channel = providePreferredChannel(),
- itemId = bookId,
- width = width,
- )
+ val localResult = localCacheRepository.fetchBookCover(bookId)
+ if (localResult is OperationResult.Success) {
+ return localResult
}
+
+ return cachedCoverProvider.provideCover(
+ channel = providePreferredChannel(),
+ itemId = bookId,
+ width = width,
+ )
}
suspend fun searchBooks(
@@ -97,46 +107,88 @@ class LissenMediaProvider
): OperationResult> {
Timber.d("Searching books with query $query of library: $libraryId")
- return when (preferences.isForceCache()) {
- true -> localCacheRepository.searchBooks(libraryId = libraryId, query = query)
- false ->
- providePreferredChannel()
- .searchBooks(
- libraryId = libraryId,
- query = query,
- limit = limit,
- )
+ val localResult = localCacheRepository.searchBooks(libraryId = libraryId, query = query)
+ if (localResult is OperationResult.Success && localResult.data.isNotEmpty()) {
+ return localResult
}
+
+ return providePreferredChannel()
+ .searchBooks(
+ libraryId = libraryId,
+ query = query,
+ limit = limit,
+ )
}
suspend fun fetchBooks(
libraryId: String,
pageSize: Int,
pageNumber: Int,
+ downloadedOnly: Boolean = false,
): OperationResult> {
- Timber.d("Fetching page $pageNumber of library: $libraryId")
+ Timber.d("Fetching page $pageNumber of library: $libraryId. Downloaded only: $downloadedOnly")
+
+ val localResult =
+ localCacheRepository.fetchBooks(
+ libraryId = libraryId,
+ pageSize = pageSize,
+ pageNumber = pageNumber,
+ downloadedOnly = downloadedOnly,
+ )
- return when (preferences.isForceCache()) {
- true -> localCacheRepository.fetchBooks(libraryId = libraryId, pageSize = pageSize, pageNumber = pageNumber)
- false -> providePreferredChannel().fetchBooks(libraryId = libraryId, pageSize = pageSize, pageNumber = pageNumber)
+ if (downloadedOnly) {
+ return localResult
}
+
+ val localItems =
+ localResult.fold(
+ onSuccess = { it.items },
+ onFailure = { emptyList() },
+ )
+
+ if (localItems.isEmpty()) {
+ Timber.d("Local cache miss for page $pageNumber. Fetching from remote.")
+ return providePreferredChannel()
+ .fetchBooks(
+ libraryId = libraryId,
+ pageSize = pageSize,
+ pageNumber = pageNumber,
+ ).also {
+ it.foldAsync(
+ onSuccess = { result -> localCacheRepository.cacheBooks(result.items) },
+ onFailure = {},
+ )
+ }
+ }
+
+ return localResult
}
+ suspend fun syncLibraryPage(
+ libraryId: String,
+ pageSize: Int,
+ pageNumber: Int,
+ ): OperationResult =
+ providePreferredChannel()
+ .fetchBooks(libraryId, pageSize, pageNumber)
+ .map { localCacheRepository.cacheBooks(it.items) }
+
suspend fun fetchLibraries(): OperationResult> {
Timber.d("Fetching List of libraries")
- return when (preferences.isForceCache()) {
- true -> localCacheRepository.fetchLibraries()
- false ->
- providePreferredChannel()
- .fetchLibraries()
- .also {
- it.foldAsync(
- onSuccess = { libraries -> localCacheRepository.updateLibraries(libraries) },
- onFailure = {},
- )
- }
+ val localResult = localCacheRepository.fetchLibraries()
+ if (localResult is OperationResult.Success && localResult.data.isNotEmpty()) {
+ return localResult
}
+
+ return providePreferredChannel()
+ .fetchLibraries()
+ .also {
+ it.foldAsync(
+ onSuccess = { libraries -> localCacheRepository.updateLibraries(libraries) },
+ onFailure = {},
+ )
+ }
}
suspend fun startPlayback(
@@ -158,32 +210,80 @@ class LissenMediaProvider
suspend fun fetchRecentListenedBooks(libraryId: String): OperationResult> {
Timber.d("Fetching Recent books of library $libraryId")
- return when (preferences.isForceCache()) {
- true -> localCacheRepository.fetchRecentListenedBooks(libraryId)
- false ->
- providePreferredChannel()
- .fetchRecentListenedBooks(libraryId)
- .map { items -> syncFromLocalProgress(libraryId = libraryId, detailedItems = items) }
+ val isOffline = !networkService.isServerAvailable.value || preferences.isForceCache()
+
+ val localResult =
+ localCacheRepository.fetchRecentListenedBooks(
+ libraryId = libraryId,
+ downloadedOnly = isOffline,
+ )
+
+ if (isOffline) {
+ return localResult
+ }
+
+ if (localResult is OperationResult.Success && localResult.data.isNotEmpty()) {
+ return localResult
}
+
+ return providePreferredChannel()
+ .fetchRecentListenedBooks(libraryId)
+ .map { items -> syncFromLocalProgress(libraryId = libraryId, detailedItems = items) }
}
suspend fun fetchBook(bookId: String): OperationResult {
Timber.d("Fetching Detailed book info for $bookId")
- return when (preferences.isForceCache()) {
- true ->
- localCacheRepository
- .fetchBook(bookId)
- ?.let { OperationResult.Success(it) }
- ?: OperationResult.Error(OperationError.InternalError)
-
- false ->
- providePreferredChannel()
- .fetchBook(bookId)
- .map { syncFromLocalProgress(it) }
+ val localResult = localCacheRepository.fetchBook(bookId)
+ val isDetailed =
+ localResult
+ ?.let { it.chapters.isNotEmpty() || it.files.isNotEmpty() }
+ ?: false
+
+ if (localResult != null && isDetailed) {
+ return OperationResult.Success(makeAvailableIfOnline(localResult))
}
+
+ return providePreferredChannel()
+ .fetchBook(bookId)
+ .map { syncFromLocalProgress(it) }
+ .also {
+ it.foldAsync(
+ onSuccess = { book -> localCacheRepository.cacheBookMetadata(book) },
+ onFailure = {},
+ )
+ }.map { makeAvailableIfOnline(it) }
}
+ private fun makeAvailableIfOnline(book: DetailedItem): DetailedItem {
+ if (!networkService.isNetworkAvailable()) {
+ return book
+ }
+
+ val isAllCached = book.chapters.all { it.available }
+ if (isAllCached) {
+ return book
+ }
+
+ return book.copy(
+ chapters = book.chapters.map { it.copy(available = true) },
+ )
+ }
+
+ fun fetchBookFlow(bookId: String): Flow =
+ localCacheRepository
+ .fetchBookFlow(bookId)
+ .combine(networkService.networkStatus) { book: DetailedItem?, isOnline: Boolean ->
+ if (book == null) return@combine null
+
+ val isAllCached = book.chapters.all { it.available }
+ if (!isOnline || isAllCached) return@combine book
+
+ book.copy(
+ chapters = book.chapters.map { it.copy(available = true) },
+ )
+ }
+
suspend fun authorize(
host: String,
username: String,
@@ -254,21 +354,61 @@ class LissenMediaProvider
)
}
+ suspend fun syncRepositories() {
+ val libraryId = preferences.getPreferredLibrary()?.id ?: return
+ val remoteRecents = providePreferredChannel().fetchRecentListenedBooks(libraryId).getOrNull() ?: emptyList()
+ val localRecents =
+ localCacheRepository
+ .fetchRecentListenedBooks(
+ libraryId = libraryId,
+ downloadedOnly = false,
+ ).getOrNull() ?: emptyList()
+
+ val remoteMap = remoteRecents.associateBy { it.id }
+ val localMap = localRecents.associateBy { it.id }
+ val allIds = (remoteMap.keys + localMap.keys).toSet()
+
+ for (id in allIds) {
+ val remote = remoteMap[id]
+ val local = localMap[id]
+
+ val remoteTime = remote?.listenedLastUpdate ?: 0L
+ val localTime = local?.listenedLastUpdate ?: 0L
+
+ if (remoteTime > localTime) {
+ providePreferredChannel().fetchBook(id).foldAsync(
+ onSuccess = { localCacheRepository.cacheBookMetadata(it) },
+ onFailure = {},
+ )
+ }
+ }
+ }
+
+ private fun OperationResult.getOrNull(): T? =
+ when (this) {
+ is OperationResult.Success -> data
+ is OperationResult.Error -> null
+ }
+
private suspend fun syncFromLocalProgress(
libraryId: String,
detailedItems: List,
): List {
val localRecentlyBooks =
localCacheRepository
- .fetchRecentListenedBooks(libraryId)
- .fold(
+ .fetchRecentListenedBooks(
+ libraryId = libraryId,
+ downloadedOnly = false,
+ ).fold(
onSuccess = { it },
onFailure = { return@fold detailedItems },
)
+ val localMap = localRecentlyBooks.associateBy { it.id }
+
val syncedRecentlyBooks =
detailedItems
- .mapNotNull { item -> localRecentlyBooks.find { it.id == item.id }?.let { item to it } }
+ .mapNotNull { item -> localMap[item.id]?.let { item to it } }
.map { (remote, local) ->
val localTimestamp = local.listenedLastUpdate ?: return@map remote
val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote
@@ -279,10 +419,12 @@ class LissenMediaProvider
}
}
+ val syncedMap = syncedRecentlyBooks.associateBy { it.id }
+
return detailedItems
.map { item ->
- syncedRecentlyBooks
- .find { item.id == it.id }
+ syncedMap
+ .get(item.id)
?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) }
?: item
}
@@ -291,22 +433,25 @@ class LissenMediaProvider
private suspend fun syncFromLocalProgress(detailedItem: DetailedItem): DetailedItem {
val cachedBook = localCacheRepository.fetchBook(detailedItem.id) ?: return detailedItem
+ // Always prefer local progress if available and newer?
+ // Or if we just fetched from remote (in caller), and remote is newer, we use remote?
+ // But here we are "syncing FROM local".
+ // If we seeked offline, local is newer.
+ // If we seeked online on other device, remote is newer.
+ // We need timestamp check.
+
val cachedProgress = cachedBook.progress ?: return detailedItem
val channelProgress = detailedItem.progress
+ // If channel progress is null, use cached.
+ if (channelProgress == null) return detailedItem.copy(progress = cachedProgress)
+
val updatedProgress =
- listOfNotNull(cachedProgress, channelProgress)
- .maxByOrNull { it.lastUpdate }
- ?: return detailedItem
-
- Timber.d(
- """
- Merging local playback progress into channel-fetched:
- Channel Progress: $channelProgress
- Cached Progress: $cachedProgress
- Final Progress: $updatedProgress
- """.trimIndent(),
- )
+ if (cachedProgress.lastUpdate > channelProgress.lastUpdate) {
+ cachedProgress
+ } else {
+ channelProgress
+ }
return detailedItem.copy(progress = updatedProgress)
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt
index e24fd4fff..36785af07 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt
@@ -110,7 +110,7 @@ class ContentCachingManager
findRequestedFiles(item, listOf(chapter))
.forEach { file ->
- val binaryContent = properties.provideMediaCachePatch(item.id, file.id)
+ val binaryContent = properties.provideMediaCachePath(item.id, file.id)
if (binaryContent.exists()) {
binaryContent.delete()
@@ -119,7 +119,13 @@ class ContentCachingManager
}
suspend fun dropCache(itemId: String) {
- bookRepository.removeBook(itemId)
+ val book = bookRepository.fetchBook(itemId) ?: return
+
+ bookRepository.cacheBook(
+ book = book,
+ fetchedChapters = emptyList(),
+ droppedChapters = book.chapters,
+ )
val cachedContent: File = properties.provideBookCache(itemId)
@@ -135,6 +141,8 @@ class ContentCachingManager
chapterId: String,
) = bookRepository.provideCacheState(mediaItemId, chapterId)
+ fun hasDownloadedChapters(mediaItemId: String) = bookRepository.hasDownloadedChapters(mediaItemId)
+
private suspend fun cacheBookMedia(
bookId: String,
files: List,
@@ -163,7 +171,7 @@ class ContentCachingManager
}
val body = response.body
- val dest = properties.provideMediaCachePatch(bookId, file.id)
+ val dest = properties.provideMediaCachePath(bookId, file.id)
dest.parentFile?.mkdirs()
try {
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt
index dccbe0494..72a705089 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt
@@ -2,8 +2,11 @@ package org.grakovne.lissen.content.cache.persistent
import android.net.Uri
import androidx.core.net.toFile
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
import org.grakovne.lissen.channel.common.OperationError
import org.grakovne.lissen.channel.common.OperationResult
+import org.grakovne.lissen.content.cache.common.findRelatedFiles
import org.grakovne.lissen.content.cache.persistent.api.CachedBookRepository
import org.grakovne.lissen.content.cache.persistent.api.CachedLibraryRepository
import org.grakovne.lissen.lib.domain.Book
@@ -14,6 +17,7 @@ import org.grakovne.lissen.lib.domain.PagedItems
import org.grakovne.lissen.lib.domain.PlaybackProgress
import org.grakovne.lissen.lib.domain.RecentBook
import org.grakovne.lissen.playback.service.calculateChapterIndex
+import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -24,6 +28,7 @@ class LocalCacheRepository
constructor(
private val cachedBookRepository: CachedBookRepository,
private val cachedLibraryRepository: CachedLibraryRepository,
+ private val storageProperties: OfflineBookStorageProperties,
) {
fun provideFileUri(
libraryItemId: String,
@@ -84,10 +89,16 @@ class LocalCacheRepository
libraryId: String,
pageSize: Int,
pageNumber: Int,
+ downloadedOnly: Boolean = false,
): OperationResult> {
val books =
cachedBookRepository
- .fetchBooks(pageNumber = pageNumber, pageSize = pageSize, libraryId = libraryId)
+ .fetchBooks(
+ pageNumber = pageNumber,
+ pageSize = pageSize,
+ libraryId = libraryId,
+ downloadedOnly = downloadedOnly,
+ )
return OperationResult
.Success(
@@ -108,10 +119,25 @@ class LocalCacheRepository
cachedLibraryRepository.cacheLibraries(libraries)
}
- suspend fun fetchRecentListenedBooks(libraryId: String): OperationResult> =
+ suspend fun fetchRecentListenedBooks(
+ libraryId: String,
+ downloadedOnly: Boolean = false,
+ ): OperationResult> =
cachedBookRepository
- .fetchRecentBooks(libraryId)
- .let { OperationResult.Success(it) }
+ .fetchRecentBooks(
+ libraryId = libraryId,
+ downloadedOnly = downloadedOnly,
+ ).let { OperationResult.Success(it) }
+
+ fun fetchRecentListenedBooksFlow(
+ libraryId: String,
+ downloadedOnly: Boolean,
+ ): Flow> =
+ cachedBookRepository
+ .fetchRecentBooksFlow(
+ libraryId = libraryId,
+ downloadedOnly = downloadedOnly,
+ )
suspend fun fetchLatestUpdate(libraryId: String) = cachedBookRepository.fetchLatestUpdate(libraryId)
@@ -127,37 +153,38 @@ class LocalCacheRepository
* @return the detailed book item with updated playback progress if necessary,
* or `null` if the book is not found in the cache.
*/
- suspend fun fetchBook(bookId: String): DetailedItem? {
- val cachedBook =
- cachedBookRepository
- .fetchBook(bookId)
- ?: return null
-
- val cachedPosition =
- cachedBook
- .progress
- ?.currentTime
- ?: 0.0
-
- val currentChapter = calculateChapterIndex(cachedBook, cachedPosition)
-
- return when (currentChapter in cachedBook.chapters.indices && cachedBook.chapters[currentChapter].available) {
- true -> cachedBook
-
- false ->
- cachedBook
- .copy(
- progress =
- MediaProgress(
- currentTime =
- cachedBook.chapters
- .firstOrNull { it.available }
- ?.start
- ?: return null,
- isFinished = false,
- lastUpdate = 946728000000, // 2000-01-01T12:00
- ),
- )
+ suspend fun cacheBooks(books: List) {
+ cachedBookRepository.cacheBooks(books)
+ }
+
+ suspend fun fetchBook(bookId: String): DetailedItem? = cachedBookRepository.fetchBook(bookId)
+
+ suspend fun cacheBookMetadata(book: DetailedItem) {
+ try {
+ val (restoredChapters, droppedChapters) =
+ book
+ .chapters
+ .partition { chapter ->
+ val files = findRelatedFiles(chapter, book.files)
+ if (files.isEmpty()) return@partition false
+
+ files.all { file ->
+ storageProperties
+ .provideMediaCachePath(book.id, file.id)
+ .exists()
+ }
+ }
+
+ cachedBookRepository.cacheBook(
+ book = book,
+ fetchedChapters = restoredChapters,
+ droppedChapters = droppedChapters,
+ )
+ Timber.d("Successfully cached book metadata for ${book.id}")
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to cache book metadata for ${book.id}")
}
}
+
+ fun fetchBookFlow(bookId: String): Flow = cachedBookRepository.fetchBookFlow(bookId)
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt
index c9e4a43b4..f19a03a3f 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt
@@ -27,7 +27,7 @@ class OfflineBookStorageProperties
fun provideBookCache(bookId: String): File = baseFolder().resolve(bookId)
- fun provideMediaCachePatch(
+ fun provideMediaCachePath(
bookId: String,
fileId: String,
): File =
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt
index e4405625b..529977697 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt
@@ -2,6 +2,8 @@ package org.grakovne.lissen.content.cache.persistent.api
import android.net.Uri
import androidx.core.net.toUri
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
import org.grakovne.lissen.common.LibraryOrderingDirection
import org.grakovne.lissen.common.LibraryOrderingOption
import org.grakovne.lissen.content.cache.persistent.OfflineBookStorageProperties
@@ -9,6 +11,7 @@ import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityCo
import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityDetailedConverter
import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityRecentConverter
import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao
+import org.grakovne.lissen.content.cache.persistent.entity.BookEntity
import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity
import org.grakovne.lissen.lib.domain.Book
import org.grakovne.lissen.lib.domain.DetailedItem
@@ -37,7 +40,7 @@ class CachedBookRepository
fileId: String,
): Uri =
properties
- .provideMediaCachePatch(bookId, fileId)
+ .provideMediaCachePath(bookId, fileId)
.toUri()
fun provideBookCover(bookId: String): File = properties.provideBookCoverPath(bookId)
@@ -58,11 +61,54 @@ class CachedBookRepository
fun provideCacheState(bookId: String) = bookDao.isBookCached(bookId)
+ suspend fun cacheBooks(books: List) {
+ if (books.isEmpty()) return
+
+ val bookIds = books.map { it.id }
+ val existingBooks =
+ bookDao
+ .fetchBooks(bookIds)
+ .associateBy { it.id }
+
+ val entities =
+ books
+ .map { book ->
+ val existing = existingBooks[book.id]
+
+ existing?.copy(
+ title = book.title,
+ author = book.author,
+ subtitle = book.subtitle,
+ seriesNames = book.series,
+ duration = book.duration.toInt(),
+ ) ?: BookEntity(
+ id = book.id,
+ title = book.title,
+ author = book.author,
+ subtitle = book.subtitle,
+ narrator = null,
+ publisher = null,
+ year = null,
+ abstract = null,
+ duration = book.duration.toInt(),
+ libraryId = book.libraryId,
+ createdAt = 0,
+ updatedAt = 0,
+ seriesNames = book.series,
+ seriesJson = "[]",
+ )
+ }
+
+ bookDao.upsertBooks(entities)
+ }
+
fun provideCacheState(
bookId: String,
chapterId: String,
) = bookDao.isBookChapterCached(bookId, chapterId)
+ fun hasDownloadedChapters(bookId: String) = bookDao.hasDownloadedChapters(bookId)
+
suspend fun fetchCachedItems(
pageSize: Int,
pageNumber: Int,
@@ -78,6 +124,7 @@ class CachedBookRepository
libraryId: String,
pageNumber: Int,
pageSize: Int,
+ downloadedOnly: Boolean = false,
): List {
val (option, direction) = buildOrdering()
@@ -88,6 +135,7 @@ class CachedBookRepository
.pageSize(pageSize)
.orderField(option)
.orderDirection(direction)
+ .downloadedOnly(downloadedOnly)
.build()
return bookDao
@@ -116,11 +164,17 @@ class CachedBookRepository
.map { cachedBookEntityConverter.apply(it) }
}
- suspend fun fetchRecentBooks(libraryId: String): List {
- val recentBooks =
- bookDao.fetchRecentlyListenedCachedBooks(
- libraryId = libraryId,
- )
+ suspend fun fetchRecentBooks(
+ libraryId: String,
+ downloadedOnly: Boolean,
+ ): List {
+ val request =
+ RecentRequestBuilder()
+ .libraryId(libraryId)
+ .downloadedOnly(downloadedOnly)
+ .build()
+
+ val recentBooks = bookDao.fetchRecentlyListenedCachedBooks(request)
val progress =
recentBooks
@@ -132,11 +186,36 @@ class CachedBookRepository
.map { cachedBookEntityRecentConverter.apply(it, progress[it.id]) }
}
+ fun fetchRecentBooksFlow(
+ libraryId: String,
+ downloadedOnly: Boolean,
+ ): Flow> {
+ val request =
+ RecentRequestBuilder()
+ .libraryId(libraryId)
+ .downloadedOnly(downloadedOnly)
+ .build()
+
+ return bookDao
+ .fetchRecentlyListenedCachedBooksFlow(request)
+ .map { entities ->
+ entities.map { entity ->
+ val progress = entity.progress?.let { it.lastUpdate to it.currentTime }
+ cachedBookEntityRecentConverter.apply(entity.detailedBook, progress)
+ }
+ }
+ }
+
suspend fun fetchBook(bookId: String): DetailedItem? =
bookDao
.fetchCachedBook(bookId)
?.let { cachedBookEntityDetailedConverter.apply(it) }
+ fun fetchBookFlow(bookId: String): Flow =
+ bookDao
+ .fetchBookFlow(bookId)
+ .map { it?.let { cachedBookEntityDetailedConverter.apply(it) } }
+
suspend fun syncProgress(
bookId: String,
progress: PlaybackProgress,
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt
index 0d706b265..8786a396b 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt
@@ -9,6 +9,7 @@ class FetchRequestBuilder {
private var pageSize: Int = 20
private var orderField: String = "title"
private var orderDirection: String = "ASC"
+ private var downloadedOnly: Boolean = false
fun libraryId(id: String?) = apply { this.libraryId = id }
@@ -20,6 +21,8 @@ class FetchRequestBuilder {
fun orderDirection(direction: String) = apply { this.orderDirection = direction }
+ fun downloadedOnly(enabled: Boolean) = apply { this.downloadedOnly = enabled }
+
fun build(): SupportSQLiteQuery {
val args = mutableListOf()
@@ -34,8 +37,8 @@ class FetchRequestBuilder {
val field =
when (orderField) {
- "title", "author", "duration" -> orderField
- else -> "title"
+ "title", "author", "duration" -> "detailed_books.$orderField"
+ else -> "detailed_books.title"
}
val direction =
@@ -44,12 +47,19 @@ class FetchRequestBuilder {
else -> "ASC"
}
+ val joinClause =
+ when (downloadedOnly) {
+ true -> "INNER JOIN book_chapters ON detailed_books.id = book_chapters.bookId AND book_chapters.isCached = 1"
+ false -> ""
+ }
+
args.add(pageSize)
args.add(pageNumber * pageSize)
val sql =
"""
- SELECT * FROM detailed_books
+ SELECT DISTINCT detailed_books.* FROM detailed_books
+ $joinClause
WHERE $whereClause
ORDER BY $field $direction
LIMIT ? OFFSET ?
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt
new file mode 100644
index 000000000..eb05aecb6
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt
@@ -0,0 +1,49 @@
+package org.grakovne.lissen.content.cache.persistent.api
+
+import androidx.sqlite.db.SimpleSQLiteQuery
+import androidx.sqlite.db.SupportSQLiteQuery
+
+class RecentRequestBuilder {
+ private var libraryId: String? = null
+ private var downloadedOnly: Boolean = false
+ private var limit: Int = 10
+
+ fun libraryId(id: String?) = apply { this.libraryId = id }
+
+ fun downloadedOnly(enabled: Boolean) = apply { this.downloadedOnly = enabled }
+
+ fun limit(limit: Int) = apply { this.limit = limit }
+
+ fun build(): SupportSQLiteQuery {
+ val args = mutableListOf()
+
+ val whereClause =
+ when (val libraryId = libraryId) {
+ null -> "libraryId IS NULL"
+ else -> {
+ args.add(libraryId)
+ "(libraryId = ? OR libraryId IS NULL)"
+ }
+ }
+
+ val joinClause =
+ when (downloadedOnly) {
+ true -> "INNER JOIN book_chapters ON detailed_books.id = book_chapters.bookId AND book_chapters.isCached = 1"
+ false -> ""
+ }
+
+ val sql =
+ """
+ SELECT DISTINCT detailed_books.* FROM detailed_books
+ INNER JOIN media_progress ON detailed_books.id = media_progress.bookId
+ $joinClause
+ WHERE $whereClause
+ AND media_progress.currentTime > 1.0
+ AND media_progress.isFinished = 0
+ ORDER BY media_progress.lastUpdate DESC
+ LIMIT $limit
+ """.trimIndent()
+
+ return SimpleSQLiteQuery(sql, args.toTypedArray())
+ }
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt
index dcb87b0fb..c72b6bcb0 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt
@@ -34,5 +34,7 @@ class CachedBookEntityConverter
?.let { append(" #$it") }
}
},
+ duration = entity.duration.toDouble(),
+ libraryId = entity.libraryId ?: "",
)
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityRecentConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityRecentConverter.kt
index 07681dc3e..ca5eedaba 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityRecentConverter.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityRecentConverter.kt
@@ -22,7 +22,7 @@ class CachedBookEntityRecentConverter
listenedPercentage =
currentTime
?.second
- ?.let { it / entity.duration }
+ ?.let { if (entity.duration > 0) it / entity.duration else 0.0 }
?.let { it * 100 }
?.toInt(),
)
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt
index cc930fab6..b14214e41 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt
@@ -13,6 +13,7 @@ import androidx.room.Update
import androidx.sqlite.db.SupportSQLiteQuery
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
+import kotlinx.coroutines.flow.Flow
import org.grakovne.lissen.common.moshi
import org.grakovne.lissen.content.cache.persistent.entity.BookChapterEntity
import org.grakovne.lissen.content.cache.persistent.entity.BookEntity
@@ -137,21 +138,21 @@ interface CachedBookDao {
suspend fun searchBooks(query: SupportSQLiteQuery): List
@Transaction
- @RewriteQueriesToDropUnusedColumns
- @Query(
- """
- SELECT * FROM detailed_books
- INNER JOIN media_progress ON detailed_books.id = media_progress.bookId WHERE (libraryId IS NULL OR libraryId = :libraryId)
- ORDER BY media_progress.lastUpdate DESC
- LIMIT 10
- """,
- )
- suspend fun fetchRecentlyListenedCachedBooks(libraryId: String?): List
+ @RawQuery(observedEntities = [BookEntity::class, MediaProgressEntity::class])
+ fun fetchRecentlyListenedCachedBooksFlow(query: SupportSQLiteQuery): Flow>
+
+ @Transaction
+ @RawQuery
+ suspend fun fetchRecentlyListenedCachedBooks(query: SupportSQLiteQuery): List
@Transaction
@Query("SELECT * FROM detailed_books WHERE id = :bookId")
suspend fun fetchCachedBook(bookId: String): CachedBookEntity?
+ @Transaction
+ @Query("SELECT * FROM detailed_books WHERE id = :bookId")
+ fun fetchBookFlow(bookId: String): Flow
+
@Query("SELECT COUNT(*) > 0 FROM detailed_books WHERE id = :bookId")
fun isBookCached(bookId: String): LiveData
@@ -186,6 +187,16 @@ interface CachedBookDao {
chapterId: String,
): LiveData
+ @Query(
+ """
+ SELECT COUNT(*) > 0
+ FROM book_chapters
+ WHERE bookId = :bookId
+ AND isCached = 1
+ """,
+ )
+ fun hasDownloadedChapters(bookId: String): LiveData
+
@Query(
"""
SELECT MAX(mp.lastUpdate)
@@ -203,6 +214,15 @@ interface CachedBookDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertBook(book: BookEntity)
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertBooks(books: List)
+
+ @Query("SELECT * FROM detailed_books WHERE id IN (:bookIds)")
+ suspend fun fetchBooks(bookIds: List): List
+
+ @Update
+ suspend fun updateBook(book: BookEntity)
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertBookFiles(files: List)
diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/CachedCoverProvider.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/CachedCoverProvider.kt
index b09b02903..5165db0d4 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/CachedCoverProvider.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/CachedCoverProvider.kt
@@ -58,18 +58,19 @@ class CachedCoverProvider
return withContext(Dispatchers.IO) {
channel
- .fetchBookCover(itemId)
+ .fetchBookCover(itemId, width)
.fold(
onSuccess = { source ->
- source.withBlur(context)
-
val blurred = source.withBlur(context)
dest.parentFile?.mkdirs()
blurred.writeToFile(dest)
OperationResult.Success(dest)
},
- onFailure = { return@fold OperationResult.Error(OperationError.InternalError, it.message) },
+ onFailure = {
+ Timber.e("Failed to cache cover $itemId with width: $width. Error: ${it.message} Code: ${it.code}")
+ return@fold OperationResult.Error(OperationError.InternalError, it.message)
+ },
)
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt
index 6e7f5b637..18f16ea97 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt
@@ -18,11 +18,16 @@ import org.grakovne.lissen.common.LibraryOrderingConfiguration
import org.grakovne.lissen.common.NetworkTypeAutoCache
import org.grakovne.lissen.common.PlaybackVolumeBoost
import org.grakovne.lissen.common.moshi
+import org.grakovne.lissen.lib.domain.CurrentEpisodeTimerOption
import org.grakovne.lissen.lib.domain.DetailedItem
import org.grakovne.lissen.lib.domain.DownloadOption
+import org.grakovne.lissen.lib.domain.DurationTimerOption
import org.grakovne.lissen.lib.domain.Library
import org.grakovne.lissen.lib.domain.LibraryType
import org.grakovne.lissen.lib.domain.SeekTime
+import org.grakovne.lissen.lib.domain.SmartRewindDuration
+import org.grakovne.lissen.lib.domain.SmartRewindInactivityThreshold
+import org.grakovne.lissen.lib.domain.TimerOption
import org.grakovne.lissen.lib.domain.connection.LocalUrl
import org.grakovne.lissen.lib.domain.connection.ServerRequestHeader
import org.grakovne.lissen.lib.domain.makeDownloadOption
@@ -430,6 +435,163 @@ class LissenSharedPreferences
}
}
+ fun saveShowPlayerNavButtons(show: Boolean) =
+ sharedPreferences.edit {
+ putBoolean(KEY_SHOW_PLAYER_NAV_BUTTONS, show)
+ }
+
+ fun getShowPlayerNavButtons(): Boolean = sharedPreferences.getBoolean(KEY_SHOW_PLAYER_NAV_BUTTONS, false)
+
+ fun saveShakeToResetTimer(enabled: Boolean) =
+ sharedPreferences.edit {
+ putBoolean(KEY_SHAKE_TO_RESET_TIMER, enabled)
+ }
+
+ fun getShakeToResetTimer(): Boolean = sharedPreferences.getBoolean(KEY_SHAKE_TO_RESET_TIMER, true)
+
+ fun saveSmartRewindEnabled(enabled: Boolean) =
+ sharedPreferences.edit {
+ putBoolean(KEY_SMART_REWIND_ENABLED, enabled)
+ }
+
+ fun getSmartRewindEnabled(): Boolean = sharedPreferences.getBoolean(KEY_SMART_REWIND_ENABLED, false)
+
+ fun saveSmartRewindThreshold(threshold: SmartRewindInactivityThreshold) =
+ sharedPreferences.edit {
+ putString(KEY_SMART_REWIND_THRESHOLD, threshold.name)
+ }
+
+ fun getSmartRewindThreshold(): SmartRewindInactivityThreshold =
+ sharedPreferences
+ .getString(KEY_SMART_REWIND_THRESHOLD, SmartRewindInactivityThreshold.Default.name)
+ .let { safeEnumValueOf(it, SmartRewindInactivityThreshold.Default) }
+
+ fun saveSmartRewindDuration(duration: SmartRewindDuration) =
+ sharedPreferences.edit {
+ putString(KEY_SMART_REWIND_DURATION, duration.name)
+ }
+
+ fun getSmartRewindDuration(): SmartRewindDuration =
+ sharedPreferences
+ .getString(KEY_SMART_REWIND_DURATION, SmartRewindDuration.Default.name)
+ .let { safeEnumValueOf(it, SmartRewindDuration.Default) }
+
+ private inline fun > safeEnumValueOf(
+ value: String?,
+ default: T,
+ ): T {
+ if (value == null) return default
+ return try {
+ enumValueOf(value)
+ } catch (e: Exception) {
+ default
+ }
+ }
+
+ val showPlayerNavButtonsFlow: Flow =
+ callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_SHOW_PLAYER_NAV_BUTTONS) {
+ trySend(getShowPlayerNavButtons())
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ trySend(getShowPlayerNavButtons())
+ awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+ }.distinctUntilChanged()
+
+ val shakeToResetTimerFlow: Flow =
+ callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_SHAKE_TO_RESET_TIMER) {
+ trySend(getShakeToResetTimer())
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ trySend(getShakeToResetTimer())
+ awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+ }.distinctUntilChanged()
+
+ val smartRewindEnabledFlow: Flow =
+ callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_SMART_REWIND_ENABLED) {
+ trySend(getSmartRewindEnabled())
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ trySend(getSmartRewindEnabled())
+ awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+ }.distinctUntilChanged()
+
+ val smartRewindThresholdFlow: Flow =
+ callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_SMART_REWIND_THRESHOLD) {
+ trySend(getSmartRewindThreshold())
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ trySend(getSmartRewindThreshold())
+ awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+ }.distinctUntilChanged()
+
+ val forceCacheFlow: Flow =
+ callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == CACHE_FORCE_ENABLED) {
+ trySend(isForceCache())
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ trySend(isForceCache())
+ awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+ }.distinctUntilChanged()
+
+ val smartRewindDurationFlow: Flow =
+ callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_SMART_REWIND_DURATION) {
+ trySend(getSmartRewindDuration())
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ trySend(getSmartRewindDuration())
+ awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+ }.distinctUntilChanged()
+
+ val preferredLibraryIdFlow: Flow =
+ callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_PREFERRED_LIBRARY_ID) {
+ trySend(getPreferredLibraryId())
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ trySend(getPreferredLibraryId())
+ awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+ }.distinctUntilChanged()
+
+ val hostFlow: Flow =
+ callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_HOST) {
+ trySend(getHost())
+ }
+ }
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ trySend(getHost())
+ awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+ }.distinctUntilChanged()
+
companion object {
private const val KEY_ALIAS = "secure_key_alias"
private const val KEY_HOST = "host"
@@ -439,6 +601,10 @@ class LissenSharedPreferences
private const val KEY_TOKEN = "token"
private const val CACHE_FORCE_ENABLED = "cache_force_enabled"
+ private const val KEY_SMART_REWIND_ENABLED = "smart_rewind_enabled"
+ private const val KEY_SMART_REWIND_THRESHOLD = "smart_rewind_threshold"
+ private const val KEY_SMART_REWIND_DURATION = "smart_rewind_duration"
+
private const val KEY_SERVER_VERSION = "server_version"
private const val KEY_DEVICE_ID = "device_id"
@@ -461,6 +627,9 @@ class LissenSharedPreferences
private const val KEY_BYPASS_SSL = "bypass_ssl"
private const val KEY_LOCAL_URLS = "local_urls"
+ private const val KEY_SHOW_PLAYER_NAV_BUTTONS = "show_player_nav_buttons"
+ private const val KEY_SHAKE_TO_RESET_TIMER = "shake_to_reset_timer"
+
private const val KEY_PLAYING_BOOK = "playing_book"
private const val KEY_VOLUME_BOOST = "volume_boost"
diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt
index befaa097e..a22c75245 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt
@@ -5,6 +5,8 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
+import android.media.AudioManager
+import android.media.ToneGenerator
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.Lifecycle
@@ -26,6 +28,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.grakovne.lissen.content.LissenMediaProvider
@@ -45,6 +48,7 @@ import org.grakovne.lissen.playback.service.PlaybackService.Companion.TIMER_OPTI
import org.grakovne.lissen.playback.service.PlaybackService.Companion.TIMER_REMAINING
import org.grakovne.lissen.playback.service.PlaybackService.Companion.TIMER_TICK
import org.grakovne.lissen.playback.service.PlaybackService.Companion.TIMER_VALUE_EXTRA
+import org.grakovne.lissen.playback.service.ShakeDetector
import org.grakovne.lissen.playback.service.calculateChapterIndex
import org.grakovne.lissen.playback.service.calculateChapterPosition
import timber.log.Timber
@@ -59,7 +63,10 @@ class MediaRepository
@ApplicationContext private val context: Context,
private val preferences: LissenSharedPreferences,
private val mediaChannel: LissenMediaProvider,
+ private val shakeDetector: ShakeDetector,
) {
+ fun getBookFlow(bookId: String): Flow = mediaChannel.fetchBookFlow(bookId)
+
private lateinit var mediaController: MediaController
private val token =
@@ -90,6 +97,9 @@ class MediaRepository
private val _mediaPreparingError = MutableLiveData()
val mediaPreparingError: LiveData = _mediaPreparingError
+ private val _preparingBookId = MutableLiveData()
+ val preparingBookId: LiveData = _preparingBookId
+
private val _playbackSpeed = MutableLiveData(preferences.getPlaybackSpeed())
val playbackSpeed: LiveData = _playbackSpeed
@@ -119,6 +129,8 @@ class MediaRepository
private val handler = Handler(Looper.getMainLooper())
+ private var pendingChapterIndex: Int? = null
+
init {
val controllerBuilder = MediaController.Builder(context, token)
val futureController = controllerBuilder.buildAsync()
@@ -145,6 +157,7 @@ class MediaRepository
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_isPlaying.value = isPlaying
+ updateShakeDetectorState(preferences.getShakeToResetTimer(), isPlaying)
}
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -163,6 +176,12 @@ class MediaRepository
},
MoreExecutors.directExecutor(),
)
+
+ CoroutineScope(Dispatchers.Main).launch {
+ preferences.shakeToResetTimerFlow.collect { enabled ->
+ updateShakeDetectorState(enabled, isPlaying.value == true)
+ }
+ }
}
private val playbackReadyReceiver =
@@ -184,6 +203,12 @@ class MediaRepository
preferences.savePlayingBook(it)
_isPlaybackReady.postValue(true)
+ _preparingBookId.postValue(null)
+
+ pendingChapterIndex?.let { index ->
+ setChapter(index)
+ pendingChapterIndex = null
+ }
if (_playAfterPrepare.value == true) {
_playAfterPrepare.postValue(false)
@@ -195,6 +220,9 @@ class MediaRepository
}
}
+ // ... existing timer receivers ...
+
+ // ... existing updateTimer ...
private val timerExpiredReceiver =
object : BroadcastReceiver() {
override fun onReceive(
@@ -256,6 +284,39 @@ class MediaRepository
}
}
+ private fun resetSleepTimer() {
+ val currentOption = _timerOption.value
+ if (currentOption != null) {
+ updateTimer(currentOption)
+ playResetSound()
+ }
+ }
+
+ private fun playResetSound() {
+ try {
+ val toneGen = ToneGenerator(AudioManager.STREAM_MUSIC, 100)
+ toneGen.startTone(ToneGenerator.TONE_PROP_BEEP)
+ Handler(Looper.getMainLooper()).postDelayed({
+ toneGen.release()
+ }, 200)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to play reset sound")
+ }
+ }
+
+ private fun updateShakeDetectorState(
+ enabled: Boolean,
+ isPlaying: Boolean,
+ ) {
+ if (enabled && isPlaying) {
+ shakeDetector.start {
+ resetSleepTimer()
+ }
+ } else {
+ shakeDetector.stop()
+ }
+ }
+
fun rewind() {
totalPosition
.value
@@ -310,9 +371,18 @@ class MediaRepository
}
}
- fun prepareAndPlay(book: DetailedItem) {
- when (isPlaybackReady.value) {
- true -> play()
+ fun prepareAndPlay(
+ book: DetailedItem,
+ chapterIndex: Int? = null,
+ ) {
+ val isDifferentBook = playingBook.value?.id != book.id
+ this.pendingChapterIndex = chapterIndex
+
+ when {
+ !isDifferentBook && isPlaybackReady.value == true -> {
+ chapterIndex?.let { setChapter(it) }
+ play()
+ }
else -> {
_playAfterPrepare.postValue(true)
startPreparingPlayback(book)
@@ -357,12 +427,21 @@ class MediaRepository
.fetchBook(bookId)
.foldAsync(
onSuccess = { startPreparingPlayback(it) },
- onFailure = { _mediaPreparingError.postValue(true) },
+ onFailure = {
+ _mediaPreparingError.postValue(true)
+ _preparingBookId.postValue(null)
+ },
)
}
}
}
+ suspend fun fetchBook(bookId: String) {
+ withContext(Dispatchers.IO) {
+ mediaChannel.fetchBook(bookId)
+ }
+ }
+
fun nextTrack() {
val book = playingBook.value ?: return
val overallPosition = totalPosition.value ?: return
@@ -439,6 +518,7 @@ class MediaRepository
private fun startPreparingPlayback(book: DetailedItem) {
if (_playingBook.value != book) {
+ _preparingBookId.postValue(book.id)
_totalPosition.postValue(0.0)
_isPlaying.postValue(false)
@@ -464,7 +544,7 @@ class MediaRepository
_totalPosition.postValue(accumulated + currentFilePosition)
}
- private fun play() {
+ fun play() {
val intent =
Intent(context, PlaybackService::class.java).apply {
action = PlaybackService.ACTION_PLAY
@@ -529,6 +609,7 @@ class MediaRepository
context.startService(intent)
adjustTimer(safePosition)
+ _totalPosition.postValue(safePosition)
}
private fun adjustTimer(position: Double) {
diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactory.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactory.kt
index 0d0a58bfc..7b354aa7a 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactory.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactory.kt
@@ -1,11 +1,13 @@
package org.grakovne.lissen.playback.service
import android.content.Context
+import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.datasource.TransferListener
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
@@ -56,12 +58,20 @@ class LissenDataSourceFactory(
}
override fun createDataSource(): DataSource {
- val actualDataSource = defaultFactory.createDataSource()
-
- return object : DataSource by actualDataSource {
+ val cacheDataSource = defaultFactory.createDataSource()
+ val defaultDataSource = DefaultDataSource(baseContext, false)
+
+ return object : DataSource {
+ private var currentDataSource: DataSource? = null
+
+ override fun addTransferListener(transferListener: TransferListener) {
+ cacheDataSource.addTransferListener(transferListener)
+ defaultDataSource.addTransferListener(transferListener)
+ }
+
override fun open(dataSpec: DataSpec): Long {
val (itemId, fileId) = unapply(dataSpec.uri) ?: return 0
-
+
val resolvedUri =
mediaProvider
.provideFileUri(itemId, fileId)
@@ -71,12 +81,40 @@ class LissenDataSourceFactory(
)
Timber.d("Resolved Uri: $resolvedUri for itemId = $itemId and fileId = $fileId")
-
- return dataSpec
- .buildUpon()
- .setUri(resolvedUri)
- .build()
- .let { actualDataSource.open(it) }
+
+ val newSpec =
+ dataSpec
+ .buildUpon()
+ .setUri(resolvedUri)
+ .build()
+
+ val source =
+ when (resolvedUri.scheme) {
+ "file" -> defaultDataSource
+ else -> cacheDataSource
+ }
+
+ currentDataSource = source
+ return source.open(newSpec)
+ }
+
+ override fun read(
+ buffer: ByteArray,
+ offset: Int,
+ length: Int,
+ ): Int {
+ val dataSource =
+ currentDataSource
+ ?: throw IllegalStateException("DataSource is not open. Call open() first.")
+
+ return dataSource.read(buffer, offset, length)
+ }
+
+ override fun getUri(): Uri? = currentDataSource?.uri
+
+ override fun close() {
+ currentDataSource?.close()
+ currentDataSource = null
}
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt
index 663c8a547..83ae0010b 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt
@@ -63,6 +63,8 @@ class PlaybackService : MediaSessionService() {
private var session: MediaSession? = null
+ private var smartRewindApplied = false
+
private val playerServiceScope = MainScope()
override fun onCreate() {
@@ -99,6 +101,7 @@ class PlaybackService : MediaSessionService() {
ACTION_PLAY -> {
playerServiceScope
.launch {
+ checkAndApplySmartRewind()
exoPlayer.prepare()
exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed())
exoPlayer.playWhenReady = true
@@ -157,6 +160,7 @@ class PlaybackService : MediaSessionService() {
@OptIn(UnstableApi::class)
private suspend fun preparePlayback(book: DetailedItem) {
exoPlayer.playWhenReady = false
+ smartRewindApplied = false
withContext(Dispatchers.IO) {
val prepareQueue =
@@ -199,7 +203,15 @@ class PlaybackService : MediaSessionService() {
exoPlayer.setMediaSources(playingQueue)
exoPlayer.prepare()
- setPlaybackProgress(book.files, book.progress)
+ val startPosition = calculateSmartRewindPosition(book)
+ val currentPosition = book.progress?.currentTime ?: 0.0
+
+ if (startPosition < currentPosition) {
+ Timber.d("Smart rewind applied. Seeking to $startPosition from $currentPosition")
+ smartRewindApplied = true
+ }
+
+ seek(book.files, startPosition)
}
}
@@ -221,6 +233,55 @@ class PlaybackService : MediaSessionService() {
}
}
+ private suspend fun checkAndApplySmartRewind() {
+ if (smartRewindApplied) {
+ return
+ }
+
+ val item = exoPlayer.currentMediaItem?.localConfiguration?.tag as? DetailedItem ?: return
+
+ withContext(Dispatchers.IO) {
+ val book =
+ mediaProvider
+ .fetchBook(item.id)
+ .fold(
+ onSuccess = { it },
+ onFailure = { item },
+ )
+
+ withContext(Dispatchers.Main) {
+ val startPosition = calculateSmartRewindPosition(book)
+ val currentPosition = book.progress?.currentTime ?: 0.0
+
+ if (startPosition < currentPosition) {
+ Timber.d("Smart rewind applied (on resume). Seeking to $startPosition from $currentPosition")
+ seek(book.files, startPosition)
+ smartRewindApplied = true
+ }
+ }
+ }
+ }
+
+ private fun calculateSmartRewindPosition(book: DetailedItem): Double =
+ when (sharedPreferences.getSmartRewindEnabled()) {
+ true -> {
+ val lastUpdate = book.progress?.lastUpdate ?: 0L
+ val currentTime = System.currentTimeMillis()
+ val threshold = sharedPreferences.getSmartRewindThreshold().durationMillis
+ val rewindDuration = sharedPreferences.getSmartRewindDuration().durationSeconds.toDouble()
+
+ val currentPosition = book.progress?.currentTime ?: 0.0
+
+ if (currentTime - lastUpdate > threshold) {
+ (currentPosition - rewindDuration).coerceAtLeast(0.0)
+ } else {
+ currentPosition
+ }
+ }
+
+ false -> book.progress?.currentTime ?: 0.0
+ }
+
private suspend fun fetchCover(book: DetailedItem) =
mediaProvider
.fetchBookCover(
@@ -244,6 +305,7 @@ class PlaybackService : MediaSessionService() {
}
private fun pause() {
+ smartRewindApplied = false
playerServiceScope
.launch {
exoPlayer.playWhenReady = false
diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt
index 01f99b6fa..bbd60a40d 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt
@@ -56,6 +56,7 @@ class PlaybackSynchronizationService
fun cancelSynchronization() {
syncJob?.cancel()
+ runSync()
}
private fun handleSyncEvent() {
@@ -111,6 +112,7 @@ class PlaybackSynchronizationService
currentChapterIndex = currentIndex
}
+ mediaChannel.syncLocalProgress(currentItem?.id ?: return@launch, overallProgress)
playbackSession?.let { requestSync(it, overallProgress) }
} catch (e: Exception) {
Timber.e(e, "Error during sync")
@@ -180,7 +182,7 @@ class PlaybackSynchronizationService
}
companion object {
- private const val SYNC_INTERVAL_LONG = 30_000L
+ private const val SYNC_INTERVAL_LONG = 10_000L
private const val SHORT_SYNC_WINDOW = SYNC_INTERVAL_LONG * 2 - 1
private const val SYNC_INTERVAL_SHORT = 5_000L
@@ -190,6 +192,7 @@ class PlaybackSynchronizationService
Player.EVENT_MEDIA_ITEM_TRANSITION,
Player.EVENT_PLAYBACK_STATE_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED,
+ Player.EVENT_POSITION_DISCONTINUITY,
)
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackTimer.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackTimer.kt
index 4f1b09329..be648f4d6 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackTimer.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackTimer.kt
@@ -30,11 +30,9 @@ class PlaybackTimer
override fun onIsPlayingChanged(isPlaying: Boolean) {
val currentTimer = timer ?: return
- if (option == CurrentEpisodeTimerOption) {
- when (isPlaying) {
- true -> timer = currentTimer.resume()
- false -> currentTimer.pause()
- }
+ when (isPlaying) {
+ true -> timer = currentTimer.resume()
+ false -> currentTimer.pause()
}
}
}
@@ -45,6 +43,7 @@ class PlaybackTimer
option: TimerOption,
) {
stopTimer()
+ exoPlayer.volume = 1f
val totalMillis = (delayInSeconds * 1000).toLong()
if (totalMillis <= 0L) return
@@ -55,7 +54,10 @@ class PlaybackTimer
SuspendableCountDownTimer(
totalMillis = totalMillis,
intervalMillis = 500L,
- onTickSeconds = { seconds -> broadcastRemaining(seconds) },
+ onTickSeconds = { seconds ->
+ broadcastRemaining(seconds)
+ handleVolumeFade(seconds, delayInSeconds)
+ },
onFinished = {
localBroadcastManager.sendBroadcast(Intent(PlaybackService.TIMER_EXPIRED))
stopTimer()
@@ -66,7 +68,7 @@ class PlaybackTimer
exoPlayer.addListener(playerListener)
this.option = option
- if (exoPlayer.isPlaying.not() && option == CurrentEpisodeTimerOption) {
+ if (exoPlayer.isPlaying.not()) {
timer?.pause()
}
}
@@ -82,7 +84,30 @@ class PlaybackTimer
fun stopTimer() {
timer?.cancel()
timer = null
+ exoPlayer.volume = 1f
exoPlayer.removeListener(playerListener)
}
+
+ private fun handleVolumeFade(
+ remainingSeconds: Long,
+ totalSeconds: Double,
+ ) {
+ val fadeWindow = 60.0 // Fade out over last 60 seconds
+
+ // If remaining is large, max volume
+ if (remainingSeconds > fadeWindow) {
+ if (exoPlayer.volume != 1f) exoPlayer.volume = 1f
+ return
+ }
+
+ // Calculate progress 0.0 (end) to 1.0 (start of fade)
+ // If total duration is very short (e.g. 30s), fade over the whole duration?
+ // Let's stick to max 60s fade.
+
+ val progress = (remainingSeconds / fadeWindow).toFloat().coerceIn(0f, 1f)
+
+ // Use a simple linear fade or squared for smoother perception
+ exoPlayer.volume = progress
+ }
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/ShakeDetector.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/ShakeDetector.kt
new file mode 100644
index 000000000..78458ecc4
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/ShakeDetector.kt
@@ -0,0 +1,140 @@
+package org.grakovne.lissen.playback.service
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import androidx.media3.common.util.UnstableApi
+import dagger.hilt.android.qualifiers.ApplicationContext
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+@UnstableApi
+class ShakeDetector
+ @Inject
+ constructor(
+ @ApplicationContext private val context: Context,
+ ) : SensorEventListener {
+ private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
+ private val accelerometer: Sensor? = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
+
+ @Volatile
+ private var onShake: (() -> Unit)? = null
+
+ @Volatile
+ private var lastUpdate: Long = 0
+
+ @Volatile
+ private var lastX: Float = 0f
+
+ @Volatile
+ private var lastY: Float = 0f
+
+ @Volatile
+ private var lastZ: Float = 0f
+
+ @Volatile
+ private var lastShakeTimestamp: Long = 0
+
+ // Configurable Thresholds
+ private val timeThreshold = 1000L // Minimum time between shakes in ms
+
+ private val lock = Any()
+
+ fun start(onShake: () -> Unit) {
+ synchronized(lock) {
+ this.onShake = onShake
+ }
+
+ if (accelerometer != null) {
+ sensorManager?.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL)
+ } else {
+ Timber.w("Accelerometer not supported on this device.")
+ }
+ }
+
+ fun stop() {
+ sensorManager?.unregisterListener(this)
+
+ synchronized(lock) {
+ onShake = null
+ }
+ }
+
+ override fun onSensorChanged(event: SensorEvent?) {
+ event ?: return
+
+ if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
+ val curTime = System.currentTimeMillis()
+ val x = event.values[0]
+ val y = event.values[1]
+ val z = event.values[2]
+
+ var diffTime = 0L
+ var prevX = 0f
+ var prevY = 0f
+ var prevZ = 0f
+ var shouldProcess = false
+
+ // First synchronized block: Read/Update State
+ synchronized(lock) {
+ if ((curTime - lastUpdate) > 100) {
+ diffTime = (curTime - lastUpdate)
+ lastUpdate = curTime
+
+ prevX = lastX
+ prevY = lastY
+ prevZ = lastZ
+
+ lastX = x
+ lastY = y
+ lastZ = z
+ shouldProcess = true
+ }
+ }
+
+ if (!shouldProcess) return
+
+ // Calculation outside lock
+ val deltaX = x - prevX
+ val deltaY = y - prevY
+ val deltaZ = z - prevZ
+
+ val speed = Math.sqrt((deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ).toDouble()) / diffTime * 10000
+
+ if (speed > SHAKE_THRESHOLD) {
+ val now = System.currentTimeMillis()
+ var callback: (() -> Unit)? = null
+
+ // Second synchronized block: Throttle & Callback Retrieval
+ synchronized(lock) {
+ if (now - lastShakeTimestamp > timeThreshold) {
+ lastShakeTimestamp = now
+ callback = onShake
+ }
+ }
+
+ // Execution outside lock
+ if (callback != null) {
+ Timber.d("Shake detected! Speed: $speed")
+ mainHandler.post { callback?.invoke() }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val SHAKE_THRESHOLD = 800
+ }
+
+ override fun onAccuracyChanged(
+ sensor: Sensor?,
+ accuracy: Int,
+ ) {
+ // No-op
+ }
+ }
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt
index f255afaaf..96a3bcf1c 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt
@@ -41,7 +41,7 @@ fun AsyncShimmeringImage(
Modifier
.fillMaxSize()
.shimmer()
- .background(Color.Gray),
+ .background(androidx.compose.material3.MaterialTheme.colorScheme.surfaceVariant),
)
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt
new file mode 100644
index 000000000..3c5a4b9e5
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt
@@ -0,0 +1,59 @@
+package org.grakovne.lissen.ui.components
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CloudDownload
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.ProgressBarRangeInfo
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.progressBarRangeInfo
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.grakovne.lissen.R
+import org.grakovne.lissen.content.cache.persistent.CacheState
+import org.grakovne.lissen.lib.domain.CacheStatus
+
+@Composable
+fun DownloadProgressIcon(
+ cacheState: CacheState,
+ size: Dp = 24.dp,
+ color: Color = LocalContentColor.current,
+) {
+ if (cacheState.status is CacheStatus.Caching) {
+ val progress = cacheState.progress.coerceIn(0.0, 1.0).toFloat()
+ val progressPercent = (progress * 100).toInt()
+ val progressDescription = stringResource(R.string.download_progress_description, progressPercent)
+
+ val iconSize = size - 2.dp
+ CircularProgressIndicator(
+ progress = { progress },
+ modifier =
+ Modifier
+ .semantics(mergeDescendants = true) {
+ progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f)
+ contentDescription = progressDescription
+ }.size(iconSize),
+ strokeWidth = iconSize * 0.1f,
+ color = colorScheme.primary,
+ trackColor = color,
+ strokeCap = StrokeCap.Butt,
+ gapSize = 2.dp,
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Outlined.CloudDownload,
+ contentDescription = stringResource(R.string.player_screen_downloads_navigation),
+ modifier = Modifier.size(size),
+ tint = color,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/effects/WindowBlurEffect.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/effects/WindowBlurEffect.kt
new file mode 100644
index 000000000..c59d06931
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/effects/WindowBlurEffect.kt
@@ -0,0 +1,21 @@
+package org.grakovne.lissen.ui.effects
+
+import android.app.Activity
+import android.os.Build.VERSION
+import android.os.Build.VERSION_CODES
+import android.view.Window
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.platform.LocalView
+
+@Composable
+fun WindowBlurEffect() {
+ val view = LocalView.current
+ if (VERSION.SDK_INT >= VERSION_CODES.S) {
+ SideEffect {
+ val activity = view.context as? Activity ?: return@SideEffect
+ val window = activity.window
+ window.setBackgroundBlurRadius(30)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt
index c5f23a313..6e40bdb54 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt
@@ -33,6 +33,7 @@ import org.grakovne.lissen.ui.screens.settings.advanced.LocalUrlSettingsScreen
import org.grakovne.lissen.ui.screens.settings.advanced.SeekSettingsScreen
import org.grakovne.lissen.ui.screens.settings.advanced.cache.CacheSettingsScreen
import org.grakovne.lissen.ui.screens.settings.advanced.cache.CachedItemsSettingsScreen
+import org.grakovne.lissen.ui.screens.settings.playback.PlaybackSettingsScreen
@Composable
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@@ -87,184 +88,221 @@ fun AppNavHost(
) + fadeOut(animationSpec = tween())
Scaffold(modifier = Modifier.fillMaxSize()) { _ ->
- NavHost(
- navController = navController,
- startDestination = startDestination,
+
+ org.grakovne.lissen.ui.screens.player.GlobalPlayerBottomSheet(
+ navController = navigationService,
+ imageLoader = imageLoader,
) {
- composable(
- route = "settings_screen/cached_items",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) {
- CachedItemsSettingsScreen(
- onBack = {
- if (navController.previousBackStackEntry != null) {
- navController.popBackStack()
- }
- },
- imageLoader = imageLoader,
- )
- }
- composable(
- route = "settings_screen/cache_settings",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) {
- CacheSettingsScreen(
- navController = navigationService,
- onBack = {
- if (navController.previousBackStackEntry != null) {
- navController.popBackStack()
- }
- },
- )
- }
- composable(
- route = "library_screen",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
+ NavHost(
+ navController = navController,
+ startDestination = startDestination,
) {
- LibraryScreen(
- navController = navigationService,
- imageLoader = imageLoader,
- networkService = networkService,
- )
- }
-
- composable(
- route = "player_screen/{bookId}?bookTitle={bookTitle}&bookSubtitle={bookSubtitle}&startInstantly={startInstantly}",
- arguments =
- listOf(
- navArgument("bookId") { type = NavType.StringType },
- navArgument("bookTitle") {
- type = NavType.StringType
- nullable = true
- },
- navArgument("bookSubtitle") {
- type = NavType.StringType
- nullable = true
+ composable(
+ route = "settings_screen/cached_items",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ CachedItemsSettingsScreen(
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
},
- navArgument("startInstantly") {
- type = NavType.BoolType
- nullable = false
+ imageLoader = imageLoader,
+ )
+ }
+ composable(
+ route = "settings_screen/cache_settings",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ CacheSettingsScreen(
+ navController = navigationService,
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
},
- ),
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) { navigationStack ->
- val bookId = navigationStack.arguments?.getString("bookId") ?: return@composable
- val bookTitle = navigationStack.arguments?.getString("bookTitle") ?: ""
- val bookSubtitle = navigationStack.arguments?.getString("bookSubtitle")
- val startInstantly = navigationStack.arguments?.getBoolean("startInstantly")
+ )
+ }
+ composable(
+ route = "library_screen",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ LibraryScreen(
+ navController = navigationService,
+ imageLoader = imageLoader,
+ networkService = networkService,
+ )
+ }
- PlayerScreen(
- navController = navigationService,
- imageLoader = imageLoader,
- bookId = bookId,
- bookTitle = bookTitle,
- bookSubtitle = bookSubtitle,
- playInstantly = startInstantly ?: false,
- )
- }
+ composable(
+ route = "player_screen/{bookId}?bookTitle={bookTitle}&bookSubtitle={bookSubtitle}&startInstantly={startInstantly}",
+ arguments =
+ listOf(
+ navArgument("bookId") { type = NavType.StringType },
+ navArgument("bookTitle") {
+ type = NavType.StringType
+ nullable = true
+ },
+ navArgument("bookSubtitle") {
+ type = NavType.StringType
+ nullable = true
+ },
+ navArgument("startInstantly") {
+ type = NavType.BoolType
+ nullable = false
+ },
+ ),
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) { navigationStack ->
+ val bookId = navigationStack.arguments?.getString("bookId") ?: return@composable
+ val bookTitle = navigationStack.arguments?.getString("bookTitle") ?: ""
+ // We ignore playInstantly here because we want to show details first,
+ // or the GlobalPlayer will pick it up if it plays.
- composable(
- route = "login_screen",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) {
- LoginScreen(navigationService)
- }
+ org.grakovne.lissen.ui.screens.details.BookDetailScreen(
+ navController = navigationService,
+ imageLoader = imageLoader,
+ bookId = bookId,
+ bookTitle = bookTitle,
+ )
+ }
- composable(
- route = "settings_screen",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) {
- SettingsScreen(
- onBack = {
- if (navController.previousBackStackEntry != null) {
- navController.popBackStack()
- }
- },
- navController = navigationService,
- )
- }
+ composable(
+ route = "login_screen",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ LoginScreen(navigationService)
+ }
- composable(
- route = "settings_screen/local_url",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) {
- LocalUrlSettingsScreen(
- onBack = {
- if (navController.previousBackStackEntry != null) {
- navController.popBackStack()
- }
- },
- )
- }
+ composable(
+ route = "settings_screen",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ SettingsScreen(
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
+ },
+ navController = navigationService,
+ )
+ }
- composable(
- route = "settings_screen/custom_headers",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) {
- CustomHeadersSettingsScreen(
- onBack = {
- if (navController.previousBackStackEntry != null) {
- navController.popBackStack()
- }
- },
- )
- }
+ composable(
+ route = "settings_screen/local_url",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ LocalUrlSettingsScreen(
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
+ },
+ )
+ }
- composable(
- route = "settings_screen/advanced_settings",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) {
- AdvancedSettingsComposable(
- navController = navigationService,
- onBack = {
- if (navController.previousBackStackEntry != null) {
- navController.popBackStack()
- }
- },
- )
- }
+ composable(
+ route = "settings_screen/custom_headers",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ CustomHeadersSettingsScreen(
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
+ },
+ )
+ }
- composable(
- route = "settings_screen/seek_settings",
- enterTransition = { enterTransition },
- exitTransition = { exitTransition },
- popEnterTransition = { popEnterTransition },
- popExitTransition = { popExitTransition },
- ) {
- SeekSettingsScreen(
- onBack = {
- if (navController.previousBackStackEntry != null) {
- navController.popBackStack()
- }
- },
- )
+ composable(
+ route = "settings_screen/advanced_settings",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ AdvancedSettingsComposable(
+ navController = navigationService,
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
+ },
+ )
+ }
+
+ composable(
+ route = "settings_screen/playback_settings",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ PlaybackSettingsScreen(
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
+ },
+ navController = navigationService,
+ )
+ }
+
+ composable(
+ route = "settings_screen/seek_settings",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ SeekSettingsScreen(
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
+ },
+ )
+ }
+
+ composable(
+ route = "settings_screen/smart_rewind_settings",
+ enterTransition = { enterTransition },
+ exitTransition = { exitTransition },
+ popEnterTransition = { popEnterTransition },
+ popExitTransition = { popExitTransition },
+ ) {
+ org.grakovne.lissen.ui.screens.settings.playback.SmartRewindSettingsScreen(
+ onBack = {
+ if (navController.previousBackStackEntry != null) {
+ navController.popBackStack()
+ }
+ },
+ )
+ }
}
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavigationService.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavigationService.kt
index 06572a7dd..cec056bd0 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavigationService.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavigationService.kt
@@ -16,6 +16,8 @@ class AppNavigationService(
}
}
+ fun back() = host.popBackStack()
+
fun showPlayer(
bookId: String,
bookTitle: String,
@@ -40,10 +42,14 @@ class AppNavigationService(
fun showSeekSettings() = host.navigate("$ROUTE_SETTINGS/seek_settings")
+ fun showSmartRewindSettings() = host.navigate("$ROUTE_SETTINGS/smart_rewind_settings")
+
fun showCachedItemsSettings() = host.navigate("$ROUTE_SETTINGS/cached_items")
fun showCacheSettings() = host.navigate("$ROUTE_SETTINGS/cache_settings")
+ fun showPlaybackSettings() = host.navigate("$ROUTE_SETTINGS/playback_settings")
+
fun showAdvancedSettings() = host.navigate("$ROUTE_SETTINGS/advanced_settings")
fun showLogin() {
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt
new file mode 100644
index 000000000..690300f7d
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt
@@ -0,0 +1,708 @@
+package org.grakovne.lissen.ui.screens.details
+
+import android.graphics.Typeface
+import android.text.Spanned
+import android.text.style.ForegroundColorSpan
+import android.text.style.StrikethroughSpan
+import android.text.style.StyleSpan
+import android.text.style.URLSpan
+import android.text.style.UnderlineSpan
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.AvTimer
+import androidx.compose.material.icons.filled.HourglassEmpty
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.outlined.Business
+import androidx.compose.material.icons.outlined.CalendarMonth
+import androidx.compose.material.icons.outlined.MicNone
+import androidx.compose.material.icons.outlined.Person
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+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.alpha
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.text.HtmlCompat
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import coil3.ImageLoader
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.size.Size
+import kotlinx.coroutines.launch
+import org.grakovne.lissen.R
+import org.grakovne.lissen.common.withHaptic
+import org.grakovne.lissen.content.cache.persistent.CacheState
+import org.grakovne.lissen.lib.domain.CacheStatus
+import org.grakovne.lissen.lib.domain.DetailedItem
+import org.grakovne.lissen.lib.domain.LibraryType
+import org.grakovne.lissen.ui.components.AsyncShimmeringImage
+import org.grakovne.lissen.ui.components.DownloadProgressIcon
+import org.grakovne.lissen.ui.extensions.formatTime
+import org.grakovne.lissen.ui.navigation.AppNavigationService
+import org.grakovne.lissen.ui.screens.player.composable.DownloadsComposable
+import org.grakovne.lissen.ui.screens.player.composable.PlaylistItemComposable
+import org.grakovne.lissen.viewmodel.CachingModelView
+import org.grakovne.lissen.viewmodel.PlayerViewModel
+import org.grakovne.lissen.viewmodel.SettingsViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookDetailScreen(
+ navController: AppNavigationService,
+ imageLoader: ImageLoader,
+ bookId: String,
+ bookTitle: String,
+ playerViewModel: PlayerViewModel = hiltViewModel(),
+ settingsViewModel: SettingsViewModel = hiltViewModel(),
+ cachingModelView: CachingModelView = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val view = LocalView.current
+ val playingBook by playerViewModel.book.observeAsState()
+ val bookDetail by playerViewModel.getBookFlow(bookId).collectAsState(initial = null)
+ val isPlaybackReady by playerViewModel.isPlaybackReady.observeAsState(false)
+ val isPlaying by playerViewModel.isPlaying.observeAsState(false)
+ val preferredLibrary by settingsViewModel.preferredLibrary.observeAsState()
+ val isOnline by playerViewModel.isOnline.collectAsState(initial = false)
+ val preparingBookId by playerViewModel.preparingBookId.observeAsState(null)
+ val currentChapterIndex by playerViewModel.currentChapterIndex.observeAsState(-1)
+ val totalPosition by playerViewModel.totalPosition.observeAsState(0.0)
+
+ val cacheProgress: CacheState by cachingModelView.getProgress(bookId).collectAsState(initial = CacheState(CacheStatus.Idle))
+ val hasDownloadedChapters by cachingModelView.hasDownloadedChapters(bookId).observeAsState(false)
+ var downloadsExpanded by remember { mutableStateOf(false) }
+ val scope = androidx.compose.runtime.rememberCoroutineScope()
+
+ LaunchedEffect(bookId) {
+ timber.log.Timber.d("BookDetailScreen: Launched with bookId $bookId")
+ }
+
+ LaunchedEffect(bookDetail) {
+ timber.log.Timber.d("BookDetailScreen: bookDetail changed to ${bookDetail?.id}, title: ${bookDetail?.title}")
+ }
+
+ LaunchedEffect(bookId) {
+ playerViewModel.fetchBook(bookId)
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Dynamic Background
+ bookDetail?.let { book ->
+ val imageRequest =
+ remember(book.id) {
+ ImageRequest
+ .Builder(context)
+ .data(book.id)
+ .size(Size.ORIGINAL)
+ .build()
+ }
+
+ val blurModifier =
+ if (android.os.Build.VERSION.SDK_INT >= 31) {
+ Modifier.blur(radius = 80.dp)
+ } else {
+ Modifier
+ }
+
+ AsyncImage(
+ model = imageRequest,
+ imageLoader = imageLoader,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .then(blurModifier)
+ .alpha(0.6f),
+ )
+
+ // Scrim
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors =
+ listOf(
+ MaterialTheme.colorScheme.background.copy(alpha = 0.2f),
+ MaterialTheme.colorScheme.background.copy(alpha = 0.8f),
+ ),
+ ),
+ ),
+ )
+ }
+
+ Scaffold(
+ containerColor = Color.Transparent,
+ topBar = {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
+ title = {},
+ navigationIcon = {
+ IconButton(onClick = { navController.back() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ tint = colorScheme.onSurface,
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { downloadsExpanded = true }) {
+ DownloadProgressIcon(
+ cacheState = cacheProgress,
+ color = colorScheme.onSurface,
+ )
+ }
+ },
+ )
+ },
+ modifier = Modifier.systemBarsPadding(),
+ ) { innerPadding ->
+ if (bookDetail == null) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else {
+ val book = bookDetail!!
+ val chapters = book.chapters
+ val maxDuration = chapters.maxOfOrNull { it.duration } ?: 0.0
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(innerPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ item {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ // Cover Image
+ val imageRequest =
+ remember(book.id) {
+ ImageRequest
+ .Builder(context)
+ .data(book.id)
+ .build()
+ }
+
+ Box(
+ modifier =
+ Modifier
+ .padding(top = 16.dp, bottom = 16.dp)
+ .height(260.dp) // Large cover
+ .aspectRatio(1f)
+ .shadow(12.dp, RoundedCornerShape(12.dp))
+ .clip(RoundedCornerShape(12.dp)),
+ ) {
+ AsyncShimmeringImage(
+ imageRequest = imageRequest,
+ imageLoader = imageLoader,
+ contentDescription = "${book.title} cover",
+ contentScale = ContentScale.FillBounds,
+ modifier = Modifier.fillMaxSize(),
+ error = painterResource(R.drawable.cover_fallback),
+ )
+ }
+
+ // Title & Author
+ Text(
+ text = book.title,
+ style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
+ color = colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 24.dp),
+ )
+
+ book.author?.takeIf { it.isNotBlank() }?.let {
+ Text(
+ text = stringResource(R.string.book_detail_author_pattern, it),
+ style = typography.titleMedium,
+ color = colorScheme.onSurface.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 4.dp, start = 24.dp, end = 24.dp),
+ )
+ }
+
+ book.narrator?.takeIf { it.isNotEmpty() }?.let {
+ Text(
+ text = stringResource(R.string.book_detail_narrator_pattern, it),
+ style = typography.bodyMedium,
+ color = colorScheme.onSurface.copy(alpha = 0.5f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 2.dp, start = 24.dp, end = 24.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Action Button
+ Button(
+ onClick = {
+ withHaptic(view) {
+ if (playingBook?.id == bookId) {
+ playerViewModel.togglePlayPause()
+ } else {
+ playerViewModel.playBook(book)
+ }
+ }
+ },
+ enabled = preparingBookId != bookId,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .height(56.dp),
+ ) {
+ if (preparingBookId == bookId) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = colorScheme.onPrimary,
+ strokeWidth = 2.dp,
+ )
+ } else {
+ val isCurrentBookPlaying = playingBook?.id == bookId && isPlaying
+
+ val icon = if (isCurrentBookPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow
+ Icon(imageVector = icon, contentDescription = null, tint = colorScheme.onPrimary)
+
+ Spacer(modifier = Modifier.width(8.dp))
+ val isCompleted = book.progress?.isFinished == true
+ val isStarted = (book.progress?.currentTime ?: 0.0) > 0
+
+ val buttonText =
+ when {
+ isCurrentBookPlaying -> stringResource(R.string.book_detail_action_pause)
+ isCompleted -> stringResource(R.string.book_detail_action_listen_again)
+ isStarted -> stringResource(R.string.book_detail_action_resume)
+ else -> stringResource(R.string.book_detail_action_start)
+ }
+
+ Text(
+ text = buttonText,
+ style = typography.titleMedium,
+ color = colorScheme.onPrimary,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Details
+ Column(modifier = Modifier.padding(horizontal = 24.dp)) {
+ val totalDuration = chapters.sumOf { it.duration }
+ val currentPosition = book.progress?.currentTime ?: 0.0
+ val isStarted = currentPosition > 0 && book.progress?.isFinished != true
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Max),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // Published Tile
+ BookInfoTile(
+ label = stringResource(R.string.book_detail_published),
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ ) {
+ Text(
+ text = book.year?.takeIf { it.isNotBlank() } ?: stringResource(R.string.book_detail_unknown_year),
+ style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Black),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ // Length/Remaining Tile
+ val durationLabel =
+ if (isStarted) {
+ stringResource(
+ R.string.book_detail_remaining,
+ )
+ } else {
+ stringResource(R.string.book_detail_length)
+ }
+ var durationSeconds =
+ if (isStarted) {
+ (totalDuration - currentPosition).toLong()
+ } else {
+ totalDuration.toLong()
+ }
+
+ val hours = durationSeconds / 3600
+ val minutes = (durationSeconds % 3600) / 60
+
+ BookInfoTile(
+ label = durationLabel,
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ ) {
+ if (hours > 0) {
+ Text(
+ text = context.resources.getQuantityString(R.plurals.duration_hours, hours.toInt(), hours.toInt()),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Black),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ if (minutes > 0) {
+ Text(
+ text = context.resources.getQuantityString(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt()),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ } else {
+ Text(
+ text = context.resources.getQuantityString(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt()),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Black),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+
+ // Publisher Tile
+ BookInfoTile(
+ label = stringResource(R.string.playing_item_details_publisher),
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ ) {
+ Text(
+ text = book.publisher?.takeIf { it.isNotBlank() } ?: stringResource(R.string.book_detail_unknown_publisher),
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Black),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ book.abstract?.takeIf { it.isNotEmpty() }?.let {
+ Spacer(modifier = Modifier.height(16.dp))
+ ExpandableDescription(it)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = stringResource(R.string.player_screen_chapter_list_title),
+ style = typography.titleMedium.copy(fontWeight = FontWeight.Black),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+
+ itemsIndexed(chapters) { index, chapter ->
+ val isCached by cachingModelView.provideCacheState(book.id, chapter.id).observeAsState(false)
+ val isPlayingChapter = playingBook?.id == bookId && currentChapterIndex == index
+
+ val overallPosition =
+ when {
+ playingBook?.id == bookId -> totalPosition
+ else -> book.progress?.currentTime ?: 0.0
+ }
+
+ val chapterStart = chapter.start
+ val chapterEnd = chapterStart + chapter.duration
+
+ val progressRaw = (overallPosition - chapterStart) / (chapterEnd - chapterStart)
+ val progress =
+ when {
+ overallPosition >= chapterEnd -> 1f
+ overallPosition <= chapterStart -> 0f
+ else -> progressRaw.toFloat()
+ }
+
+ PlaylistItemComposable(
+ track = chapter,
+ onClick = {
+ val isSameBook = playingBook?.id == bookId
+
+ if (isSameBook) {
+ if (currentChapterIndex == index) {
+ playerViewModel.togglePlayPause()
+ } else {
+ playerViewModel.setChapter(chapter)
+ }
+ } else {
+ // Check if this chapter is the "last ongoing" one
+ val currentTime = book.progress?.currentTime ?: 0.0
+ val isLastOngoing = currentTime >= chapter.start && currentTime < (chapter.start + chapter.duration)
+
+ if (isLastOngoing) {
+ playerViewModel.playBook(book) // Resume logic
+ } else {
+ playerViewModel.playBook(book, index)
+ }
+ }
+ },
+ isSelected = isPlayingChapter,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ maxDuration = maxDuration,
+ isCached = isCached,
+ canPlay = isCached || isOnline,
+ progress = progress,
+ )
+
+ if (index < chapters.size - 1) {
+ HorizontalDivider(
+ thickness = 1.dp,
+ modifier = Modifier.padding(start = 40.dp, end = 16.dp, top = 8.dp, bottom = 8.dp).alpha(0.1f),
+ )
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(32.dp)) // Bottom padding
+ }
+ }
+ }
+ }
+
+ if (downloadsExpanded) {
+ DownloadsComposable(
+ libraryType = preferredLibrary?.type ?: LibraryType.UNKNOWN,
+ hasCachedEpisodes = hasDownloadedChapters,
+ isOnline = isOnline,
+ cachingInProgress = cacheProgress.status is CacheStatus.Caching,
+ onRequestedDownload = { option ->
+ bookDetail?.let {
+ cachingModelView.cache(
+ mediaItem = it,
+ currentPosition = playerViewModel.totalPosition.value ?: 0.0,
+ option = option,
+ )
+ }
+ },
+ onRequestedDrop = {
+ bookDetail?.let {
+ scope.launch {
+ cachingModelView.dropCache(it.id)
+ }
+ }
+ },
+ onRequestedStop = {
+ bookDetail?.let {
+ scope.launch {
+ cachingModelView.stopCaching(it)
+ }
+ }
+ },
+ onDismissRequest = { downloadsExpanded = false },
+ )
+ }
+ }
+}
+
+@Composable
+fun DetailRow(
+ icon: ImageVector,
+ label: String,
+ value: String,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 6.dp),
+ ) {
+ Icon(imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(text = "$label: ", style = typography.bodyMedium, color = colorScheme.onSurface.copy(alpha = 0.6f))
+ Text(text = value, style = typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+@Composable
+fun ExpandableDescription(description: String) {
+ var expanded by remember { androidx.compose.runtime.mutableStateOf(false) }
+ var overflowDetected by remember { androidx.compose.runtime.mutableStateOf(false) }
+
+ val html = description.replace("\n", "
")
+ val spanned = remember(html) { HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) }
+
+ val linkColor = MaterialTheme.colorScheme.primary
+ val annotatedString = remember(spanned) { spanned.toAnnotatedString(linkColor) }
+ val uriHandler = LocalUriHandler.current
+ var layoutResult by remember { androidx.compose.runtime.mutableStateOf(null) }
+
+ Column {
+ Text(
+ text = annotatedString,
+ style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 24.sp),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
+ maxLines = if (expanded) Int.MAX_VALUE else 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .animateContentSize()
+ .pointerInput(key1 = spanned) {
+ detectTapGestures { pos ->
+ layoutResult?.let { layout ->
+ val offset = layout.getOffsetForPosition(pos)
+ annotatedString
+ .getStringAnnotations("URL", offset, offset)
+ .firstOrNull()
+ ?.let { annotation ->
+ uriHandler.openUri(annotation.item)
+ }
+ }
+ }
+ },
+ onTextLayout = { result ->
+ layoutResult = result
+ if (!expanded && result.hasVisualOverflow) {
+ overflowDetected = true
+ }
+ },
+ )
+
+ if (overflowDetected) {
+ Text(
+ text = if (expanded) stringResource(R.string.book_detail_see_less) else stringResource(R.string.book_detail_read_more),
+ style =
+ MaterialTheme.typography.labelMedium.copy(
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ ),
+ modifier =
+ Modifier
+ .clickable { expanded = !expanded }
+ .padding(top = 4.dp),
+ )
+ }
+ }
+}
+
+private fun Spanned.toAnnotatedString(linkColor: Color): androidx.compose.ui.text.AnnotatedString =
+ buildAnnotatedString {
+ append(this@toAnnotatedString.toString())
+ val spans = this@toAnnotatedString.getSpans(0, this@toAnnotatedString.length, Any::class.java)
+ spans.forEach { span ->
+ val start = this@toAnnotatedString.getSpanStart(span)
+ val end = this@toAnnotatedString.getSpanEnd(span)
+ when (span) {
+ is StyleSpan -> {
+ when (span.style) {
+ Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
+ Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
+ Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end)
+ }
+ }
+ is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
+ is StrikethroughSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end)
+ is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
+ is URLSpan -> {
+ addStyle(
+ SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
+ start,
+ end,
+ )
+ addStringAnnotation(tag = "URL", annotation = span.url, start = start, end = end)
+ }
+ }
+ }
+ }
+
+@Composable
+fun BookInfoTile(
+ label: String,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(
+ modifier =
+ modifier
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = if (isSystemInDarkTheme()) 0.15f else 0.6f))
+ .border(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
+ .padding(vertical = 12.dp, horizontal = 4.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Top,
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ content()
+ }
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt
index 5af3e57ed..7a4f29f46 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt
@@ -78,6 +78,7 @@ import org.grakovne.lissen.ui.screens.library.composables.RecentBooksComposable
import org.grakovne.lissen.ui.screens.library.composables.fallback.LibraryFallbackComposable
import org.grakovne.lissen.ui.screens.library.composables.placeholder.LibraryPlaceholderComposable
import org.grakovne.lissen.ui.screens.library.composables.placeholder.RecentBooksPlaceholderComposable
+import org.grakovne.lissen.ui.theme.Spacing
import org.grakovne.lissen.viewmodel.CachingModelView
import org.grakovne.lissen.viewmodel.LibraryViewModel
import org.grakovne.lissen.viewmodel.PlayerViewModel
@@ -102,11 +103,6 @@ fun LibraryScreen(
val activity = LocalActivity.current
val recentBooks: List by libraryViewModel.recentBooks.observeAsState(emptyList())
- var currentLibraryId by rememberSaveable { mutableStateOf("") }
- var localCacheUpdatedAt by rememberSaveable { mutableStateOf(0L) }
- var currentOrdering by rememberSaveable(stateSaver = LibraryOrderingConfiguration.saver) {
- mutableStateOf(LibraryOrderingConfiguration.default)
- }
var pullRefreshing by remember { mutableStateOf(false) }
val recentBookRefreshing by libraryViewModel.recentBookUpdating.observeAsState(false)
val searchRequested by libraryViewModel.searchRequested.observeAsState(false)
@@ -163,7 +159,10 @@ fun LibraryScreen(
return@derivedStateOf false
}
- pullRefreshing || recentBookRefreshing || library.loadState.refresh is LoadState.Loading
+ val hasContent = library.itemCount > 0 || recentBooks.isNotEmpty()
+ val isLoading = pullRefreshing || recentBookRefreshing || library.loadState.refresh is LoadState.Loading
+
+ isLoading && !hasContent
}
}
@@ -188,10 +187,9 @@ fun LibraryScreen(
val context = LocalContext.current
fun isRecentVisible(): Boolean {
- val fetchAvailable = networkService.isNetworkAvailable() || cachingModelView.localCacheUsing()
val hasContent = recentBooks.isEmpty().not()
- return searchRequested.not() && hasContent && fetchAvailable
+ return searchRequested.not() && hasContent
}
val showScrollbar by remember {
@@ -207,21 +205,15 @@ fun LibraryScreen(
)
LaunchedEffect(Unit) {
- val emptyContent = library.itemCount == 0
- val libraryChanged = currentLibraryId != settingsViewModel.fetchPreferredLibraryId()
- val orderingChanged = currentOrdering != settingsViewModel.fetchLibraryOrdering()
-
- val localCacheUsing = cachingModelView.localCacheUsing()
- val localCacheUpdated = cachingModelView.fetchLatestUpdate(currentLibraryId)?.let { it > localCacheUpdatedAt } ?: true
-
- if (emptyContent || libraryChanged || orderingChanged || (localCacheUsing && localCacheUpdated)) {
- libraryViewModel.refreshRecentListening()
- libraryViewModel.refreshLibrary()
-
- currentLibraryId = settingsViewModel.fetchPreferredLibraryId()
- currentOrdering = settingsViewModel.fetchLibraryOrdering()
- localCacheUpdatedAt = cachingModelView.fetchLatestUpdate(currentLibraryId) ?: 0L
- }
+ val preferredLibraryId = settingsViewModel.fetchPreferredLibraryId()
+ val latestLocalUpdate = cachingModelView.fetchLatestUpdate(preferredLibraryId)
+ val isLocalCacheUsing = cachingModelView.localCacheUsing()
+
+ libraryViewModel.checkRefreshNeeded(
+ itemCount = library.itemCount,
+ latestLocalUpdate = latestLocalUpdate,
+ isLocalCacheUsing = isLocalCacheUsing,
+ )
playerViewModel.recoverMiniPlayer()
settingsViewModel.fetchLibraries()
@@ -231,6 +223,13 @@ fun LibraryScreen(
}
}
+ LaunchedEffect(recentBooks.isNotEmpty()) {
+ val needsScroll = libraryListState.firstVisibleItemIndex <= 1
+ if (recentBooks.isNotEmpty() && needsScroll) {
+ libraryListState.animateScrollToItem(0)
+ }
+ }
+
fun provideLibraryTitle(): String {
val type = libraryViewModel.fetchPreferredLibraryType()
@@ -326,18 +325,6 @@ fun LibraryScreen(
modifier = Modifier.systemBarsPadding(),
)
},
- bottomBar = {
- playingBook?.let {
- Surface(shadowElevation = 4.dp) {
- MiniPlayerComposable(
- navController = navController,
- book = it,
- imageLoader = imageLoader,
- playerViewModel = playerViewModel,
- )
- }
- }
- },
modifier =
Modifier
.systemBarsPadding()
@@ -362,7 +349,7 @@ fun LibraryScreen(
totalItems = libraryCount,
ignoreItems = listOf("recent_books", "library_title"),
),
- contentPadding = PaddingValues(horizontal = 16.dp),
+ contentPadding = PaddingValues(horizontal = Spacing.md),
) {
item(key = "recent_books") {
val showRecent = isRecentVisible()
@@ -382,7 +369,7 @@ fun LibraryScreen(
libraryViewModel = libraryViewModel,
)
- Spacer(modifier = Modifier.height(20.dp))
+ Spacer(modifier = Modifier.height(Spacing.lg))
}
}
}
@@ -439,7 +426,7 @@ fun LibraryScreen(
}
}
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(Spacing.sm))
}
when {
@@ -487,9 +474,7 @@ fun LibraryScreen(
onDismissRequest = { preferredLibraryExpanded = false },
onItemSelected = {
settingsViewModel.preferLibrary(it)
- currentLibraryId = settingsViewModel.fetchPreferredLibraryId()
refreshContent(false)
- playerViewModel.clearPlayingBook()
preferredLibraryExpanded = false
},
)
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt
index 2dcf9942d..47d8b303d 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt
@@ -7,6 +7,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -38,7 +39,11 @@ 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.alpha
+import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
@@ -58,10 +63,11 @@ import org.grakovne.lissen.viewmodel.PlayerViewModel
@Composable
fun MiniPlayerComposable(
- navController: AppNavigationService,
book: DetailedItem,
imageLoader: ImageLoader,
playerViewModel: PlayerViewModel,
+ navController: AppNavigationService? = null,
+ onContentClick: (() -> Unit)? = null,
) {
val view: View = LocalView.current
@@ -93,6 +99,11 @@ fun MiniPlayerComposable(
SwipeToDismissBox(
state = dismissState,
+ modifier =
+ Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .shadow(elevation = 8.dp, shape = RoundedCornerShape(16.dp))
+ .clip(RoundedCornerShape(16.dp)),
backgroundContent = {
Row(
modifier =
@@ -122,79 +133,140 @@ fun MiniPlayerComposable(
visible = backgroundVisible,
exit = fadeOut(animationSpec = tween(300)),
) {
- Row(
+ val context = LocalContext.current
+ val imageRequest =
+ remember(book.id) {
+ ImageRequest
+ .Builder(context)
+ .data(book.id)
+ .build()
+ }
+
+ Box(
modifier =
Modifier
.fillMaxWidth()
- .background(colorScheme.tertiaryContainer)
- .clickable { navController.showPlayer(book.id, book.title, book.subtitle) }
- .padding(horizontal = 20.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ .background(colorScheme.surface)
+ .clickable {
+ if (onContentClick != null) {
+ onContentClick()
+ } else {
+ navController?.showPlayer(book.id, book.title, book.subtitle)
+ }
+ },
) {
- val context = LocalContext.current
- val imageRequest =
- remember(book.id) {
- ImageRequest
- .Builder(context)
- .data(book.id)
- .build()
+ val blurModifier =
+ if (android.os.Build.VERSION.SDK_INT >= 31) {
+ Modifier.blur(radius = 30.dp)
+ } else {
+ Modifier
}
AsyncShimmeringImage(
imageRequest = imageRequest,
imageLoader = imageLoader,
- contentDescription = "${book.title} cover",
- contentScale = ContentScale.FillBounds,
+ contentDescription = "",
+ contentScale = ContentScale.Crop,
modifier =
Modifier
- .size(48.dp)
- .aspectRatio(1f)
- .clip(RoundedCornerShape(4.dp)),
+ .matchParentSize()
+ .then(blurModifier)
+ .alpha(0.8f),
error = painterResource(R.drawable.cover_fallback),
)
- Spacer(modifier = Modifier.width(16.dp))
+ Box(
+ modifier =
+ Modifier
+ .matchParentSize()
+ .background(colorScheme.surface.copy(alpha = 0.7f)),
+ )
- Column(
- modifier = Modifier.weight(1f),
- ) {
- Text(
- text = book.title,
- style =
- typography.bodyMedium.copy(
- fontWeight = FontWeight.SemiBold,
- color = colorScheme.onBackground,
- ),
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
-
- book.author?.let {
- Text(
- text = it,
- style =
- typography.bodyMedium.copy(
- color = colorScheme.onBackground.copy(alpha = 0.6f),
+ Column(modifier = Modifier.fillMaxWidth()) {
+ val totalDuration = remember(book) { book.chapters.sumOf { it.duration } }
+ val currentTime = book.progress?.currentTime ?: 0.0
+ val progress = if (totalDuration > 0) (currentTime / totalDuration).toFloat() else 0f
+
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(2.dp)
+ .background(
+ colorScheme.outlineVariant
+ .copy(alpha = 0.4f),
),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth(progress)
+ .height(2.dp)
+ .background(colorScheme.primary),
)
}
- }
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- ) {
- Row {
- IconButton(
- onClick = { withHaptic(view) { playerViewModel.togglePlayPause() } },
+ Row(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AsyncShimmeringImage(
+ imageRequest = imageRequest,
+ imageLoader = imageLoader,
+ contentDescription = "${book.title} cover",
+ contentScale = ContentScale.FillBounds,
+ modifier =
+ Modifier
+ .size(48.dp)
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(4.dp)),
+ error = painterResource(R.drawable.cover_fallback),
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier.weight(1f),
) {
- Icon(
- imageVector = if (isPlaying) Icons.Outlined.PauseCircleOutline else Icons.Outlined.PlayCircle,
- contentDescription = if (isPlaying) "Pause" else "Play",
- modifier = Modifier.size(34.dp),
+ Text(
+ text = book.title,
+ style =
+ typography.bodyMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ color = colorScheme.onSurface,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
)
+
+ book.author?.let {
+ Text(
+ text = it,
+ style =
+ typography.bodyMedium.copy(
+ color = colorScheme.onBackground.copy(alpha = 0.6f),
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Row {
+ IconButton(
+ onClick = { withHaptic(view) { playerViewModel.togglePlayPause() } },
+ ) {
+ Icon(
+ imageVector = if (isPlaying) Icons.Outlined.PauseCircleOutline else Icons.Outlined.PlayCircle,
+ contentDescription = if (isPlaying) "Pause" else "Play",
+ modifier = Modifier.size(34.dp),
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/fallback/LibraryFallbackComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/fallback/LibraryFallbackComposable.kt
index 010d94e25..b25fddf76 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/fallback/LibraryFallbackComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/fallback/LibraryFallbackComposable.kt
@@ -85,7 +85,7 @@ fun LibraryFallbackComposable(
Icon(
imageVector = it,
contentDescription = "Library placeholder",
- tint = Color.White,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(64.dp),
)
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt
index cb89172bf..77889ac03 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt
@@ -45,7 +45,7 @@ fun LibraryItemPlaceholderComposable() {
.aspectRatio(1f)
.clip(RoundedCornerShape(4.dp))
.shimmer()
- .background(Color.Gray),
+ .background(androidx.compose.material3.MaterialTheme.colorScheme.surfaceVariant),
)
Spacer(modifier = Modifier.width(16.dp))
@@ -58,7 +58,7 @@ fun LibraryItemPlaceholderComposable() {
.height(16.dp)
.clip(RoundedCornerShape(4.dp))
.shimmer()
- .background(Color.Gray),
+ .background(androidx.compose.material3.MaterialTheme.colorScheme.surfaceVariant),
)
Spacer(modifier = Modifier.height(8.dp))
@@ -70,7 +70,7 @@ fun LibraryItemPlaceholderComposable() {
.height(12.dp)
.clip(RoundedCornerShape(4.dp))
.shimmer()
- .background(Color.Gray),
+ .background(androidx.compose.material3.MaterialTheme.colorScheme.surfaceVariant),
)
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/placeholder/RecentBooksPlaceholderComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/placeholder/RecentBooksPlaceholderComposable.kt
index e5f3cde45..f74a3190d 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/placeholder/RecentBooksPlaceholderComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/placeholder/RecentBooksPlaceholderComposable.kt
@@ -70,7 +70,7 @@ fun RecentBookItemComposable(
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.shimmer()
- .background(Color.Gray),
+ .background(MaterialTheme.colorScheme.surfaceVariant),
)
Spacer(modifier = Modifier.height(14.dp))
@@ -85,7 +85,7 @@ fun RecentBookItemComposable(
Modifier
.clip(RoundedCornerShape(4.dp))
.shimmer()
- .background(Color.Gray),
+ .background(MaterialTheme.colorScheme.surfaceVariant),
)
Spacer(modifier = Modifier.height(8.dp))
@@ -99,7 +99,7 @@ fun RecentBookItemComposable(
Modifier
.clip(RoundedCornerShape(4.dp))
.shimmer()
- .background(Color.Gray),
+ .background(MaterialTheme.colorScheme.surfaceVariant),
)
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt
index 350d66979..ea40bd9d1 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt
@@ -2,13 +2,14 @@ package org.grakovne.lissen.ui.screens.library.paging
import androidx.paging.PagingState
import org.grakovne.lissen.common.LibraryPagingSource
-import org.grakovne.lissen.content.LissenMediaProvider
+import org.grakovne.lissen.content.BookRepository
import org.grakovne.lissen.lib.domain.Book
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
class LibraryDefaultPagingSource(
private val preferences: LissenSharedPreferences,
- private val mediaChannel: LissenMediaProvider,
+ private val bookRepository: BookRepository,
+ private val downloadedOnly: Boolean,
onTotalCountChanged: (Int) -> Unit,
) : LibraryPagingSource(onTotalCountChanged) {
override fun getRefreshKey(state: PagingState) =
@@ -29,11 +30,12 @@ class LibraryDefaultPagingSource(
?.id
?: return LoadResult.Page(emptyList(), null, null)
- return mediaChannel
+ return bookRepository
.fetchBooks(
libraryId = libraryId,
pageSize = params.loadSize,
pageNumber = params.key ?: 0,
+ downloadedOnly = downloadedOnly,
).fold(
onSuccess = { result ->
val nextPage = if (result.items.isEmpty()) null else result.currentPage + 1
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt
index aefaefbc9..89534bbfe 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt
@@ -2,13 +2,13 @@ package org.grakovne.lissen.ui.screens.library.paging
import androidx.paging.PagingState
import org.grakovne.lissen.common.LibraryPagingSource
-import org.grakovne.lissen.content.LissenMediaProvider
+import org.grakovne.lissen.content.BookRepository
import org.grakovne.lissen.lib.domain.Book
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
class LibrarySearchPagingSource(
private val preferences: LissenSharedPreferences,
- private val mediaChannel: LissenMediaProvider,
+ private val bookRepository: BookRepository,
private val searchToken: String,
private val limit: Int,
onTotalCountChanged: (Int) -> Unit,
@@ -26,7 +26,7 @@ class LibrarySearchPagingSource(
return LoadResult.Page(emptyList(), null, null)
}
- return mediaChannel
+ return bookRepository
.searchBooks(libraryId, searchToken, limit)
.fold(
onSuccess = {
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt
new file mode 100644
index 000000000..c11ba98a0
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt
@@ -0,0 +1,352 @@
+package org.grakovne.lissen.ui.screens.player
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import coil3.ImageLoader
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.size.Size
+import kotlinx.coroutines.launch
+import org.grakovne.lissen.lib.domain.DetailedItem
+import org.grakovne.lissen.ui.navigation.AppNavigationService
+import org.grakovne.lissen.ui.screens.library.composables.MiniPlayerComposable
+import org.grakovne.lissen.ui.screens.player.composable.ChaptersBottomSheet
+import org.grakovne.lissen.ui.screens.player.composable.NavigationBarComposable
+import org.grakovne.lissen.ui.screens.player.composable.TrackControlComposable
+import org.grakovne.lissen.ui.screens.player.composable.TrackDetailsComposable
+import org.grakovne.lissen.ui.screens.player.composable.placeholder.TrackControlPlaceholderComposable
+import org.grakovne.lissen.ui.screens.player.composable.placeholder.TrackDetailsPlaceholderComposable
+import org.grakovne.lissen.viewmodel.CachingModelView
+import org.grakovne.lissen.viewmodel.LibraryViewModel
+import org.grakovne.lissen.viewmodel.PlayerViewModel
+import org.grakovne.lissen.viewmodel.SettingsViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GlobalPlayerBottomSheet(
+ navController: AppNavigationService,
+ imageLoader: ImageLoader,
+ content: @Composable () -> Unit,
+) {
+ val playerViewModel: PlayerViewModel = hiltViewModel()
+ val libraryViewModel: LibraryViewModel = hiltViewModel()
+ val settingsViewModel: SettingsViewModel = hiltViewModel()
+ val cachingModelView: CachingModelView = hiltViewModel()
+
+ val playingBook by playerViewModel.book.observeAsState()
+ val isPlaybackReady by playerViewModel.isPlaybackReady.observeAsState(false)
+
+ // We control the sheet visibility
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ var showBottomSheet by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ // Container
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Main App Content
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(bottom = if (playingBook != null && !showBottomSheet) 66.dp else 0.dp),
+ ) {
+ content()
+ }
+
+ // Mini Player (Bottom Bar)
+ // Show only if we have a book and the sheet is NOT open
+ AnimatedVisibility(
+ visible = playingBook != null && !showBottomSheet,
+ enter = slideInVertically { it } + expandVertically(),
+ exit = slideOutVertically { it } + shrinkVertically(),
+ modifier = Modifier.align(Alignment.BottomCenter),
+ ) {
+ playingBook?.let { book ->
+ Surface(
+ shadowElevation = 0.dp,
+ tonalElevation = 0.dp,
+ color = androidx.compose.ui.graphics.Color.Transparent,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.navigationBarsPadding()) {
+ // We modify MiniPlayer to accept a click action that opens the sheet
+ GlobalMiniPlayer(
+ book = book,
+ imageLoader = imageLoader,
+ playerViewModel = playerViewModel,
+ onOpenPlayer = { showBottomSheet = true },
+ )
+ }
+ }
+ }
+ }
+
+ // Full Player Bottom Sheet
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showBottomSheet = false },
+ sheetState = sheetState,
+ shape = androidx.compose.ui.graphics.RectangleShape,
+ containerColor = MaterialTheme.colorScheme.background,
+ scrimColor = androidx.compose.ui.graphics.Color.Transparent,
+ dragHandle = null,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ PlayerContent(
+ navController = navController,
+ playerViewModel = playerViewModel,
+ libraryViewModel = libraryViewModel,
+ settingsViewModel = settingsViewModel,
+ cachingModelView = cachingModelView,
+ imageLoader = imageLoader,
+ onCollapse = {
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ showBottomSheet = false
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun GlobalMiniPlayer(
+ book: DetailedItem,
+ imageLoader: ImageLoader,
+ playerViewModel: PlayerViewModel,
+ onOpenPlayer: () -> Unit,
+) {
+ // We pass a dummy NavController or null since we use onContentClick
+ // However, MiniPlayerComposable still requires a AppNavigationService in the signature.
+ // We can construct a dummy one safely because it won't be used for the click action.
+ val context = androidx.compose.ui.platform.LocalContext.current
+
+ org.grakovne.lissen.ui.screens.library.composables.MiniPlayerComposable(
+ book = book,
+ imageLoader = imageLoader,
+ playerViewModel = playerViewModel,
+ onContentClick = onOpenPlayer,
+ )
+}
+
+@Composable
+fun PlayerContent(
+ navController: AppNavigationService,
+ playerViewModel: PlayerViewModel,
+ libraryViewModel: LibraryViewModel,
+ settingsViewModel: SettingsViewModel,
+ cachingModelView: CachingModelView,
+ imageLoader: ImageLoader,
+ onCollapse: () -> Unit,
+) {
+ val playingBook by playerViewModel.book.observeAsState()
+ val isPlaybackReady by playerViewModel.isPlaybackReady.observeAsState(false)
+ val playingQueueExpanded by playerViewModel.playingQueueExpanded.observeAsState(false)
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Dynamic Background
+ playingBook?.let { book ->
+ val context = LocalContext.current
+ val imageRequest =
+ remember(book.id) {
+ ImageRequest
+ .Builder(context)
+ .data(book.id)
+ .size(Size.ORIGINAL)
+ .build()
+ }
+
+ val blurModifier =
+ if (android.os.Build.VERSION.SDK_INT >= 31) {
+ Modifier.blur(radius = 40.dp)
+ } else {
+ Modifier
+ }
+
+ AsyncImage(
+ model = imageRequest,
+ imageLoader = imageLoader,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .then(blurModifier)
+ .alpha(0.6f),
+ )
+
+ // Scrim
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)),
+ )
+ }
+
+ // We control the chapters sheet visibility
+ var showChaptersList by remember { mutableStateOf(false) }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ .padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Drag Handle / Chevron
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(32.dp)
+ .clickable(onClick = onCollapse),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ imageVector = Icons.Rounded.KeyboardArrowDown,
+ contentDescription = "Close",
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)),
+ contentScale = ContentScale.FillBounds,
+ modifier = Modifier.size(width = 64.dp, height = 32.dp),
+ )
+ }
+
+ // Track Details
+ AnimatedVisibility(
+ visible = playingQueueExpanded.not(),
+ enter = expandVertically(animationSpec = tween(400)),
+ exit = shrinkVertically(animationSpec = tween(400)),
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ if (!isPlaybackReady) {
+ TrackDetailsPlaceholderComposable("Loading...", null)
+ } else {
+ TrackDetailsComposable(
+ viewModel = playerViewModel,
+ imageLoader = imageLoader,
+ libraryViewModel = libraryViewModel,
+ onTitleClick = {
+ playingBook?.let { book ->
+ navController.showPlayer(book.id, book.title, book.subtitle, false)
+ onCollapse()
+ }
+ },
+ onChapterClick = { showChaptersList = true },
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ AnimatedVisibility(
+ visible = playingQueueExpanded.not(),
+ enter = expandVertically(animationSpec = tween(400)),
+ exit = shrinkVertically(animationSpec = tween(400)),
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ if (!isPlaybackReady) {
+ TrackControlPlaceholderComposable(
+ modifier = Modifier,
+ settingsViewModel = settingsViewModel,
+ )
+ } else {
+ TrackControlComposable(
+ viewModel = playerViewModel,
+ modifier = Modifier,
+ settingsViewModel = settingsViewModel,
+ )
+ }
+ }
+ }
+
+ // Controls (Sleep timer, etc)
+ Spacer(modifier = Modifier.weight(1f))
+
+ if (playingBook != null && isPlaybackReady) {
+ playingBook?.let {
+ NavigationBarComposable(
+ book = it,
+ playerViewModel = playerViewModel,
+ contentCachingModelView = cachingModelView,
+ settingsViewModel = settingsViewModel,
+ navController = navController,
+ libraryType = libraryViewModel.fetchPreferredLibraryType(),
+ )
+
+ if (showChaptersList) {
+ val isOnline by playerViewModel.isOnline.collectAsState(initial = false)
+
+ ChaptersBottomSheet(
+ book = it,
+ currentPosition = playerViewModel.totalPosition.value ?: 0.0,
+ currentChapterIndex = playerViewModel.currentChapterIndex.value ?: 0,
+ isOnline = isOnline,
+ cachingModelView = cachingModelView,
+ onChapterSelected = { chapter ->
+ val currentChapterIndex = playerViewModel.currentChapterIndex.value
+ val index = it.chapters.indexOf(chapter)
+
+ if (index == currentChapterIndex) {
+ playerViewModel.togglePlayPause()
+ } else {
+ playerViewModel.setChapter(chapter)
+ }
+ showChaptersList = false
+ },
+ onDismissRequest = { showChaptersList = false },
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt
index 3b4098461..23f637368 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt
@@ -59,7 +59,6 @@ import org.grakovne.lissen.ui.screens.player.composable.TrackControlComposable
import org.grakovne.lissen.ui.screens.player.composable.TrackDetailsComposable
import org.grakovne.lissen.ui.screens.player.composable.common.provideNowPlayingTitle
import org.grakovne.lissen.ui.screens.player.composable.fallback.PlayingQueueFallbackComposable
-import org.grakovne.lissen.ui.screens.player.composable.placeholder.NavigationBarPlaceholderComposable
import org.grakovne.lissen.ui.screens.player.composable.placeholder.PlayingQueuePlaceholderComposable
import org.grakovne.lissen.ui.screens.player.composable.placeholder.TrackControlPlaceholderComposable
import org.grakovne.lissen.ui.screens.player.composable.placeholder.TrackDetailsPlaceholderComposable
@@ -203,19 +202,15 @@ fun PlayerScreen(
)
},
bottomBar = {
- if (playingBook == null || isPlaybackReady.not()) {
- NavigationBarPlaceholderComposable(libraryType = libraryViewModel.fetchPreferredLibraryType())
- } else {
- playingBook
- ?.let {
- NavigationBarComposable(
- book = it,
- playerViewModel = playerViewModel,
- contentCachingModelView = cachingModelView,
- navController = navController,
- libraryType = libraryViewModel.fetchPreferredLibraryType(),
- )
- }
+ playingBook?.let {
+ NavigationBarComposable(
+ book = it,
+ playerViewModel = playerViewModel,
+ contentCachingModelView = cachingModelView,
+ settingsViewModel = settingsViewModel,
+ navController = navController,
+ libraryType = libraryViewModel.fetchPreferredLibraryType(),
+ )
}
},
modifier = Modifier.systemBarsPadding(),
@@ -319,7 +314,7 @@ fun InfoRow(
Text(
text = "$label: ",
style = typography.bodyMedium,
- color = Color.Gray,
+ color = colorScheme.onSurface.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ChaptersBottomSheet.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ChaptersBottomSheet.kt
new file mode 100644
index 000000000..a392f845a
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ChaptersBottomSheet.kt
@@ -0,0 +1,232 @@
+package org.grakovne.lissen.ui.screens.player.composable
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Audiotrack
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.grakovne.lissen.R
+import org.grakovne.lissen.lib.domain.BookChapterState
+import org.grakovne.lissen.lib.domain.DetailedItem
+import org.grakovne.lissen.lib.domain.PlayingChapter
+import org.grakovne.lissen.ui.effects.WindowBlurEffect
+import org.grakovne.lissen.ui.extensions.formatTime
+import org.grakovne.lissen.ui.theme.Spacing
+import org.grakovne.lissen.viewmodel.CachingModelView
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChaptersBottomSheet(
+ book: DetailedItem,
+ currentPosition: Double,
+ currentChapterIndex: Int,
+ isOnline: Boolean,
+ cachingModelView: CachingModelView,
+ onChapterSelected: (PlayingChapter) -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ WindowBlurEffect()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ containerColor = colorScheme.background,
+ scrimColor = colorScheme.scrim.copy(alpha = 0.65f),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = Spacing.md)
+ .padding(horizontal = Spacing.md),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.player_screen_chapter_list_title),
+ style = typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ )
+
+ Spacer(modifier = Modifier.height(Spacing.sm))
+
+ val maxDuration = book.chapters.maxOfOrNull { it.duration } ?: 0.0
+
+ LazyColumn(modifier = Modifier.fillMaxWidth()) {
+ itemsIndexed(book.chapters) { index, chapter ->
+ val isCached by cachingModelView.provideCacheState(book.id, chapter.id).observeAsState(false)
+ val isPlayingChapter = index == currentChapterIndex
+
+ val chapterStart = chapter.start
+ val chapterEnd = chapterStart + chapter.duration
+
+ val progressRaw = (currentPosition - chapterStart) / (chapterEnd - chapterStart)
+ val progress =
+ when {
+ currentPosition >= chapterEnd -> 1f
+ currentPosition <= chapterStart -> 0f
+ else -> progressRaw.toFloat()
+ }
+
+ val canPlay = isCached || isOnline
+
+ ChapterListItem(
+ chapter = chapter,
+ isPlaying = isPlayingChapter,
+ isCached = isCached,
+ canPlay = canPlay,
+ progress = progress,
+ maxDuration = maxDuration,
+ onClick = {
+ onChapterSelected(chapter)
+ },
+ )
+
+ if (index < book.chapters.size - 1) {
+ HorizontalDivider(
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChapterListItem(
+ chapter: PlayingChapter,
+ isPlaying: Boolean,
+ isCached: Boolean,
+ canPlay: Boolean,
+ progress: Float,
+ maxDuration: Double,
+ onClick: () -> Unit,
+) {
+ val fontScale = LocalDensity.current.fontScale
+ val textMeasurer = rememberTextMeasurer()
+ val density = LocalDensity.current
+
+ val forceLeadingHours = maxDuration >= 60 * 60
+ val maxDurationText = remember(maxDuration) { maxDuration.toInt().formatTime(forceLeadingHours) }
+ val bodySmallStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold)
+
+ val durationColumnWidth =
+ remember(maxDurationText, density, bodySmallStyle) {
+ with(density) {
+ textMeasurer
+ .measure(
+ text = AnnotatedString(maxDurationText),
+ style = bodySmallStyle,
+ ).size
+ .width
+ .toDp()
+ }
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(enabled = canPlay, onClick = onClick)
+ .padding(vertical = 12.dp, horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Icon Section
+ if (isPlaying) {
+ Icon(
+ imageVector = Icons.Outlined.Audiotrack,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = if (canPlay) colorScheme.primary else colorScheme.onBackground.copy(alpha = 0.4f),
+ )
+ } else if (chapter.podcastEpisodeState == BookChapterState.FINISHED || progress >= 1f) {
+ Icon(
+ imageVector = Icons.Outlined.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = if (canPlay) colorScheme.onSurface.copy(alpha = 0.4f) else colorScheme.onSurface.copy(alpha = 0.2f),
+ )
+ } else if (progress > 0f) {
+ CircularProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.size(20.dp),
+ color = if (canPlay) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f),
+ strokeWidth = 2.dp,
+ trackColor = colorScheme.onSurface.copy(alpha = 0.2f),
+ )
+ } else {
+ Spacer(modifier = Modifier.size(20.dp))
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // Title
+ Text(
+ text = chapter.title,
+ style = typography.titleSmall,
+ color = if (canPlay) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f),
+ fontWeight = if (isPlaying) FontWeight.SemiBold else FontWeight.Normal,
+ modifier = Modifier.weight(1f),
+ )
+
+ Spacer(modifier = Modifier.width(Spacing.sm))
+
+ // Offline Icon
+ if (isCached) {
+ Icon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.available_offline_filled),
+ contentDescription = "Available offline",
+ modifier = Modifier.size(Spacing.md),
+ tint = colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ )
+ Spacer(modifier = Modifier.width(Spacing.sm))
+ } else {
+ // maintain alignment
+ Spacer(modifier = Modifier.width(Spacing.md))
+ Spacer(modifier = Modifier.width(Spacing.sm))
+ }
+
+ // Duration
+ Text(
+ text = chapter.duration.toInt().formatTime(forceLeadingHours),
+ style = typography.bodySmall,
+ modifier = Modifier.width(durationColumnWidth),
+ textAlign = TextAlign.End,
+ fontWeight = if (isPlaying) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ color = if (canPlay) colorScheme.onSurfaceVariant else colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt
index 7d1e815ca..e9fa1dc16 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt
@@ -29,12 +29,13 @@ import org.grakovne.lissen.lib.domain.DownloadOption
import org.grakovne.lissen.lib.domain.LibraryType
import org.grakovne.lissen.lib.domain.NumberItemDownloadOption
import org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption
+import org.grakovne.lissen.ui.effects.WindowBlurEffect
import org.grakovne.lissen.ui.screens.common.makeText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadsComposable(
- isForceCache: Boolean,
+ isOnline: Boolean,
libraryType: LibraryType,
hasCachedEpisodes: Boolean,
cachingInProgress: Boolean,
@@ -45,8 +46,11 @@ fun DownloadsComposable(
) {
val context = LocalContext.current
+ WindowBlurEffect()
+
ModalBottomSheet(
containerColor = colorScheme.background,
+ scrimColor = colorScheme.scrim.copy(alpha = 0.65f),
onDismissRequest = onDismissRequest,
content = {
Column(
@@ -78,9 +82,9 @@ fun DownloadsComposable(
text = item.makeText(context, libraryType),
style = typography.bodyMedium,
color =
- when (isForceCache) {
- true -> colorScheme.onBackground.copy(alpha = 0.4f)
- false -> colorScheme.onBackground
+ when (isOnline) {
+ true -> colorScheme.onBackground
+ false -> colorScheme.onBackground.copy(alpha = 0.4f)
},
)
}
@@ -89,7 +93,7 @@ fun DownloadsComposable(
Modifier
.fillMaxWidth()
.clickable {
- if (isForceCache.not()) {
+ if (isOnline) {
onRequestedDownload(item)
onDismissRequest()
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt
index 43084318b..4b0a75e5f 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt
@@ -1,21 +1,24 @@
package org.grakovne.lissen.ui.screens.player.composable
+import android.content.Context
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
-import androidx.compose.material.icons.outlined.CloudDownload
import androidx.compose.material.icons.outlined.SlowMotionVideo
import androidx.compose.material.icons.outlined.Timer
-import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -26,10 +29,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
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 androidx.compose.ui.unit.dp
@@ -37,270 +42,189 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.map
import kotlinx.coroutines.launch
import org.grakovne.lissen.R
-import org.grakovne.lissen.content.cache.persistent.CacheState
-import org.grakovne.lissen.lib.domain.CacheStatus
-import org.grakovne.lissen.lib.domain.CurrentEpisodeTimerOption
+import org.grakovne.lissen.common.PlaybackVolumeBoost
import org.grakovne.lissen.lib.domain.DetailedItem
-import org.grakovne.lissen.lib.domain.DurationTimerOption
import org.grakovne.lissen.lib.domain.LibraryType
import org.grakovne.lissen.ui.extensions.formatTime
-import org.grakovne.lissen.ui.icons.TimerPlay
import org.grakovne.lissen.ui.navigation.AppNavigationService
+import org.grakovne.lissen.ui.screens.settings.composable.CommonSettingsItem
+import org.grakovne.lissen.ui.screens.settings.composable.CommonSettingsItemComposable
import org.grakovne.lissen.viewmodel.CachingModelView
import org.grakovne.lissen.viewmodel.PlayerViewModel
+import org.grakovne.lissen.viewmodel.SettingsViewModel
@Composable
fun NavigationBarComposable(
book: DetailedItem,
playerViewModel: PlayerViewModel,
contentCachingModelView: CachingModelView,
+ settingsViewModel: SettingsViewModel,
navController: AppNavigationService,
modifier: Modifier = Modifier,
libraryType: LibraryType,
) {
- val cacheProgress: CacheState by contentCachingModelView.getProgress(book.id).collectAsState()
val timerOption by playerViewModel.timerOption.observeAsState(null)
val timerRemaining by playerViewModel.timerRemaining.observeAsState(0)
val playbackSpeed by playerViewModel.playbackSpeed.observeAsState(1f)
val playingQueueExpanded by playerViewModel.playingQueueExpanded.observeAsState(false)
val hasEpisodes by playerViewModel.book.map { book.chapters.isNotEmpty() }.observeAsState(true)
-
- val isMetadataCached by contentCachingModelView.provideCacheState(book.id).observeAsState(false)
+ val preferredPlaybackVolumeBoost by settingsViewModel.preferredPlaybackVolumeBoost.observeAsState()
+ val isOnline by playerViewModel.isOnline.collectAsState(initial = false)
var playbackSpeedExpanded by remember { mutableStateOf(false) }
var timerExpanded by remember { mutableStateOf(false) }
- var downloadsExpanded by remember { mutableStateOf(false) }
+
+ var volumeBoostExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
+ val context = androidx.compose.ui.platform.LocalContext.current
Surface(
- shadowElevation = 4.dp,
- modifier = modifier.height(64.dp),
+ shadowElevation = 0.dp,
+ color = Color.Transparent,
+ modifier = modifier.padding(top = 24.dp, bottom = 12.dp, start = 12.dp, end = 12.dp),
) {
- NavigationBar(
- containerColor = Color.Transparent,
- contentColor = colorScheme.onBackground,
- modifier = Modifier.fillMaxWidth(),
+ Surface(
+ color = colorScheme.surface.copy(alpha = 0.75f),
+ shape = androidx.compose.foundation.shape.CircleShape,
+ modifier = Modifier.fillMaxWidth().height(48.dp),
) {
- val iconSize = 24.dp
- val labelStyle = typography.labelSmall.copy(fontSize = 10.sp)
-
- NavigationBarItem(
- enabled = hasEpisodes,
- icon = {
- Icon(
- Icons.AutoMirrored.Rounded.QueueMusic,
- contentDescription =
- when (libraryType) {
- LibraryType.LIBRARY -> stringResource(R.string.player_screen_chapter_list_navigation_library)
- LibraryType.PODCAST -> stringResource(R.string.player_screen_chapter_list_navigation_podcast)
- LibraryType.UNKNOWN -> stringResource(R.string.player_screen_chapter_list_navigation_items)
- },
- modifier = Modifier.size(iconSize),
- )
- },
- label = {
- Text(
- text =
- when (libraryType) {
- LibraryType.LIBRARY -> stringResource(R.string.player_screen_chapter_list_navigation_library)
- LibraryType.PODCAST -> stringResource(R.string.player_screen_chapter_list_navigation_podcast)
- LibraryType.UNKNOWN -> stringResource(R.string.player_screen_chapter_list_navigation_items)
- },
- style = labelStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- selected = playingQueueExpanded,
- onClick = { playerViewModel.togglePlayingQueue() },
- colors =
- NavigationBarItemDefaults.colors(
- selectedIconColor = colorScheme.primary,
- indicatorColor = colorScheme.surfaceContainer,
- ),
- )
-
- NavigationBarItem(
- icon = {
- DownloadProgressIcon(
- cacheState = cacheProgress,
- size = iconSize,
- )
- },
- label = {
- Text(
- text = stringResource(R.string.player_screen_downloads_navigation),
- style = labelStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- enabled = hasEpisodes,
- selected = false,
- onClick = { downloadsExpanded = true },
- colors =
- NavigationBarItemDefaults.colors(
- selectedIconColor = colorScheme.primary,
- indicatorColor = colorScheme.surfaceContainer,
- ),
- )
-
- NavigationBarItem(
- enabled = hasEpisodes,
- icon = {
- Icon(
- Icons.Outlined.SlowMotionVideo,
- contentDescription = stringResource(R.string.player_screen_playback_speed_navigation),
- modifier = Modifier.size(iconSize),
- )
- },
- label = {
- Text(
- text = stringResource(R.string.player_screen_playback_speed_navigation),
- style = labelStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- selected = false,
- onClick = { playbackSpeedExpanded = true },
- colors =
- NavigationBarItemDefaults.colors(
- selectedIconColor = colorScheme.primary,
- indicatorColor = colorScheme.surfaceContainer,
- ),
- )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val iconSize = 24.dp
+
+ PlayerActionItem(
+ icon = {
+ Icon(
+ Icons.Outlined.VolumeUp,
+ contentDescription = stringResource(R.string.volume_boost_title),
+ modifier = Modifier.size(iconSize),
+ )
+ },
+ enabled = true,
+ onClick = { volumeBoostExpanded = true },
+ )
- NavigationBarItem(
- icon = {
- Icon(
- when (timerOption) {
- null -> Icons.Outlined.Timer
- else -> TimerPlay
- },
- contentDescription = stringResource(R.string.player_screen_timer_navigation),
- modifier = Modifier.size(iconSize),
- )
- },
- label = {
- when (timerOption) {
- is DurationTimerOption, CurrentEpisodeTimerOption -> {
+ PlayerActionItem(
+ icon = {
+ if (playbackSpeed != 1f) {
Text(
- text =
- timerRemaining
- ?.toInt()
- ?.formatTime(false)
- ?: stringResource(R.string.player_screen_timer_navigation),
- style = labelStyle,
+ text = "${playbackSpeed}x",
+ style = typography.bodyMedium,
+ fontWeight = FontWeight.ExtraBold,
maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ )
+ } else {
+ Icon(
+ Icons.Outlined.SlowMotionVideo,
+ contentDescription = stringResource(R.string.player_screen_playback_speed_navigation),
+ modifier = Modifier.size(iconSize),
)
}
+ },
+ enabled = hasEpisodes,
+ onClick = { playbackSpeedExpanded = true },
+ )
- null ->
+ PlayerActionItem(
+ icon = {
+ if (timerOption != null) {
Text(
- text = stringResource(R.string.player_screen_timer_navigation),
- style = labelStyle,
+ text = (timerRemaining ?: 0).toInt().formatTime(),
+ style = typography.bodyMedium,
+ fontWeight = FontWeight.ExtraBold,
maxLines = 1,
- overflow = TextOverflow.Ellipsis,
)
- }
- },
- enabled = hasEpisodes,
- selected = false,
- onClick = { timerExpanded = true },
- colors =
- NavigationBarItemDefaults.colors(
- selectedIconColor = colorScheme.primary,
- indicatorColor = colorScheme.surfaceContainer,
- ),
- )
-
- if (playbackSpeedExpanded) {
- PlaybackSpeedComposable(
- currentSpeed = playbackSpeed,
- onSpeedChange = { playerViewModel.setPlaybackSpeed(it) },
- onDismissRequest = { playbackSpeedExpanded = false },
+ } else {
+ Icon(
+ Icons.Outlined.Timer,
+ contentDescription = stringResource(R.string.player_screen_timer_navigation),
+ modifier = Modifier.size(iconSize),
+ )
+ }
+ },
+ enabled = hasEpisodes,
+ onClick = { timerExpanded = true },
)
}
+ }
- if (timerExpanded) {
- TimerComposable(
- libraryType = libraryType,
- currentOption = timerOption,
- onOptionSelected = { playerViewModel.setTimer(it) },
- onDismissRequest = { timerExpanded = false },
- )
- }
+ if (playbackSpeedExpanded) {
+ PlaybackSpeedComposable(
+ currentSpeed = playbackSpeed,
+ onSpeedChange = { playerViewModel.setPlaybackSpeed(it) },
+ onDismissRequest = { playbackSpeedExpanded = false },
+ )
+ }
- if (downloadsExpanded) {
- DownloadsComposable(
- libraryType = libraryType,
- hasCachedEpisodes = isMetadataCached,
- isForceCache = contentCachingModelView.localCacheUsing(),
- cachingInProgress = cacheProgress.status is CacheStatus.Caching,
- onRequestedDownload = { option ->
- playerViewModel.book.value?.let {
- contentCachingModelView
- .cache(
- mediaItem = it,
- currentPosition = playerViewModel.totalPosition.value ?: 0.0,
- option = option,
- )
- }
- },
- onRequestedDrop = {
- playerViewModel
- .book
- .value
- ?.let {
- scope.launch {
- contentCachingModelView.dropCache(it.id)
+ if (timerExpanded) {
+ TimerComposable(
+ libraryType = libraryType,
+ currentOption = timerOption,
+ onOptionSelected = { playerViewModel.setTimer(it) },
+ onDismissRequest = { timerExpanded = false },
+ )
+ }
- playerViewModel.clearPlayingBook()
- navController.showLibrary(true)
- }
- }
- },
- onRequestedStop = {
- playerViewModel
- .book
- .value
- ?.let {
- scope.launch {
- contentCachingModelView.stopCaching(it)
- }
- }
- },
- onDismissRequest = { downloadsExpanded = false },
- )
- }
+ if (volumeBoostExpanded) {
+ CommonSettingsItemComposable(
+ title = stringResource(R.string.volume_boost_title),
+ items =
+ listOf(
+ PlaybackVolumeBoost.DISABLED.toItem(context),
+ PlaybackVolumeBoost.LOW.toItem(context),
+ PlaybackVolumeBoost.MEDIUM.toItem(context),
+ PlaybackVolumeBoost.HIGH.toItem(context),
+ PlaybackVolumeBoost.MAX.toItem(context),
+ ),
+ selectedItem = preferredPlaybackVolumeBoost?.toItem(context),
+ onDismissRequest = { volumeBoostExpanded = false },
+ onItemSelected = { item ->
+ PlaybackVolumeBoost
+ .entries
+ .find { it.name == item.id }
+ ?.let { settingsViewModel.preferPlaybackVolumeBoost(it) }
+ },
+ )
}
}
}
@Composable
-private fun DownloadProgressIcon(
- cacheState: CacheState,
- size: Dp,
+private fun PlayerActionItem(
+ icon: @Composable () -> Unit,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
) {
- if (cacheState.status is CacheStatus.Caching) {
- val iconSize = size - 2.dp
- CircularProgressIndicator(
- progress = { cacheState.progress.coerceIn(0.0, 1.0).toFloat() },
- modifier = Modifier.size(iconSize),
- strokeWidth = iconSize * 0.1f,
- color = colorScheme.primary,
- trackColor = LocalContentColor.current,
- strokeCap = StrokeCap.Butt,
- gapSize = 2.dp,
- )
- } else {
- Icon(
- imageVector = Icons.Outlined.CloudDownload,
- contentDescription = stringResource(R.string.player_screen_downloads_navigation),
- modifier = Modifier.size(size),
- )
+ Column(
+ horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
+ modifier =
+ modifier
+ .clickable(
+ enabled = enabled,
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onClick,
+ ).padding(horizontal = 8.dp),
+ ) {
+ icon()
}
}
+
+private fun PlaybackVolumeBoost.toItem(context: Context): CommonSettingsItem {
+ val id = this.name
+ val name =
+ when (this) {
+ PlaybackVolumeBoost.DISABLED -> context.getString(R.string.volume_boost_disabled)
+ PlaybackVolumeBoost.LOW -> context.getString(R.string.volume_boost_low)
+ PlaybackVolumeBoost.MEDIUM -> context.getString(R.string.volume_boost_medium)
+ PlaybackVolumeBoost.HIGH -> context.getString(R.string.volume_boost_high)
+ PlaybackVolumeBoost.MAX -> context.getString(R.string.volume_boost_max)
+ }
+
+ return CommonSettingsItem(id, name, null)
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackSpeedComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackSpeedComposable.kt
index 877eea8fa..9034e883b 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackSpeedComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackSpeedComposable.kt
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
import org.grakovne.lissen.R
import org.grakovne.lissen.common.withHaptic
import org.grakovne.lissen.ui.PlaybackSpeedSlider
+import org.grakovne.lissen.ui.effects.WindowBlurEffect
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@@ -40,11 +41,14 @@ fun PlaybackSpeedComposable(
onSpeedChange: (Float) -> Unit,
onDismissRequest: () -> Unit,
) {
- val view: View = LocalView.current
+ val view = LocalView.current
var selectedPlaybackSpeed by remember { mutableFloatStateOf(currentSpeed) }
+ WindowBlurEffect()
+
ModalBottomSheet(
containerColor = colorScheme.background,
+ scrimColor = colorScheme.scrim.copy(alpha = 0.65f),
onDismissRequest = onDismissRequest,
content = {
Column(
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt
index 206a137f5..fa498cfe3 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt
@@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -61,6 +62,7 @@ fun PlayingQueueComposable(
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
+ val isOnline by viewModel.isOnline.collectAsState(initial = false)
val book by viewModel.book.observeAsState()
val searchToken by viewModel.searchToken.observeAsState("")
@@ -149,11 +151,7 @@ fun PlayingQueueComposable(
modifier =
Modifier
.fillMaxHeight()
- .scrollable(
- state = rememberScrollState(),
- orientation = Orientation.Vertical,
- enabled = playingQueueExpanded,
- ).onGloballyPositioned {
+ .onGloballyPositioned {
if (collapsedPlayingQueueHeight == 0) {
collapsedPlayingQueueHeight = it.size.height
}
@@ -184,7 +182,9 @@ fun PlayingQueueComposable(
return available
}
- if (available.y > collapseFlingThreshold && playingQueueExpanded) {
+ if (available.y > collapseFlingThreshold && playingQueueExpanded && listState.firstVisibleItemIndex == 0 &&
+ listState.firstVisibleItemScrollOffset == 0
+ ) {
isFlinging.value = true
viewModel.collapsePlayingQueue()
return available
@@ -192,6 +192,18 @@ fun PlayingQueueComposable(
isFlinging.value = false
return available
}
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource,
+ ): Offset {
+ if (playingQueueExpanded && available.y > 0) {
+ viewModel.collapsePlayingQueue()
+ return available
+ }
+ return super.onPostScroll(consumed, available, source)
+ }
},
),
state = listState,
@@ -212,6 +224,7 @@ fun PlayingQueueComposable(
modifier = Modifier.wrapContentWidth(),
maxDuration = maxDuration,
isCached = isCached,
+ canPlay = isCached || isOnline,
)
if (index < showingChapters.size - 1) {
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt
index 77aed0567..dc752a7a3 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Audiotrack
import androidx.compose.material.icons.outlined.Check
+import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
@@ -41,6 +42,8 @@ fun PlaylistItemComposable(
modifier: Modifier,
maxDuration: Double,
isCached: Boolean,
+ canPlay: Boolean = true,
+ progress: Float = 0f,
) {
val fontScale = LocalDensity.current.fontScale
val textMeasurer = rememberTextMeasurer()
@@ -71,27 +74,36 @@ fun PlaylistItemComposable(
.padding(end = 4.dp)
.padding(vertical = 2.dp)
.clickable(
+ enabled = canPlay,
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() },
),
) {
- when {
- isSelected ->
- Icon(
- imageVector = Icons.Outlined.Audiotrack,
- contentDescription = stringResource(R.string.player_screen_library_playing_title),
- modifier = Modifier.size(16.dp),
- )
-
- track.podcastEpisodeState == BookChapterState.FINISHED ->
- Icon(
- imageVector = Icons.Outlined.Check,
- contentDescription = stringResource(R.string.player_screen_library_playing_title),
- modifier = Modifier.size(16.dp),
- )
-
- else -> Spacer(modifier = Modifier.size(16.dp))
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Outlined.Audiotrack,
+ contentDescription = stringResource(R.string.player_screen_library_playing_title),
+ modifier = Modifier.size(16.dp),
+ tint = if (canPlay) colorScheme.primary else colorScheme.onBackground.copy(alpha = 0.4f),
+ )
+ } else if (track.podcastEpisodeState == BookChapterState.FINISHED || progress >= 1f) {
+ Icon(
+ imageVector = Icons.Outlined.CheckCircle,
+ contentDescription = stringResource(R.string.player_screen_library_playing_title),
+ modifier = Modifier.size(16.dp),
+ tint = if (canPlay) colorScheme.onBackground.copy(alpha = 0.4f) else colorScheme.onBackground.copy(alpha = 0.2f),
+ )
+ } else if (progress > 0f) {
+ androidx.compose.material3.CircularProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.size(16.dp),
+ color = if (canPlay) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f),
+ strokeWidth = 2.dp,
+ trackColor = colorScheme.onSurface.copy(alpha = 0.2f),
+ )
+ } else {
+ Spacer(modifier = Modifier.size(16.dp))
}
Spacer(modifier = Modifier.width(8.dp))
@@ -100,7 +112,7 @@ fun PlaylistItemComposable(
text = track.title,
style = MaterialTheme.typography.titleSmall,
color =
- when (track.available) {
+ when (canPlay) {
true -> colorScheme.onBackground
false -> colorScheme.onBackground.copy(alpha = 0.4f)
},
@@ -133,8 +145,9 @@ fun PlaylistItemComposable(
modifier = Modifier.width(durationColumnWidth),
textAlign = TextAlign.End,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
color =
- when (track.available) {
+ when (canPlay) {
true -> colorScheme.onBackground.copy(alpha = 0.6f)
false -> colorScheme.onBackground.copy(alpha = 0.4f)
},
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt
index 9929d3f9e..13909e3cc 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt
@@ -32,6 +32,7 @@ import org.grakovne.lissen.lib.domain.CurrentEpisodeTimerOption
import org.grakovne.lissen.lib.domain.DurationTimerOption
import org.grakovne.lissen.lib.domain.LibraryType
import org.grakovne.lissen.lib.domain.TimerOption
+import org.grakovne.lissen.ui.effects.WindowBlurEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -43,8 +44,11 @@ fun TimerComposable(
) {
val context = LocalContext.current
+ WindowBlurEffect()
+
ModalBottomSheet(
containerColor = colorScheme.background,
+ scrimColor = colorScheme.scrim.copy(alpha = 0.65f),
onDismissRequest = onDismissRequest,
content = {
Column(
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt
index 447467b5e..b21c7b119 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt
@@ -1,6 +1,7 @@
package org.grakovne.lissen.ui.screens.player.composable
import android.view.View
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,7 +14,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.Slider
import androidx.compose.material.SliderDefaults
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PauseCircleFilled
+import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.PlayCircleFilled
import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious
@@ -21,6 +24,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -32,13 +36,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import org.grakovne.lissen.common.withHaptic
import org.grakovne.lissen.lib.domain.SeekTime
import org.grakovne.lissen.ui.extensions.formatTime
-import org.grakovne.lissen.ui.screens.player.composable.common.provideForwardIcon
-import org.grakovne.lissen.ui.screens.player.composable.common.provideReplayIcon
+import org.grakovne.lissen.ui.screens.player.composable.common.SeekButton
import org.grakovne.lissen.viewmodel.PlayerViewModel
import org.grakovne.lissen.viewmodel.SettingsViewModel
@@ -54,6 +58,7 @@ fun TrackControlComposable(
val currentTrackDuration by viewModel.currentChapterDuration.observeAsState(0.0)
val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default)
+ val showNavButtons by settingsViewModel.showPlayerNavButtons.observeAsState(true)
val book by viewModel.book.observeAsState()
val chapters = book?.chapters ?: emptyList()
@@ -62,6 +67,7 @@ fun TrackControlComposable(
var sliderPosition by remember { mutableDoubleStateOf(0.0) }
var isDragging by remember { mutableStateOf(false) }
+ var showRemainingTime by remember { mutableStateOf(false) }
LaunchedEffect(currentTrackPosition, currentTrackIndex, currentTrackDuration) {
if (!isDragging) {
@@ -117,11 +123,17 @@ fun TrackControlComposable(
)
Text(
text =
- maxOf(0.0, currentTrackDuration - sliderPosition)
- .toInt()
- .formatTime(true),
+ if (showRemainingTime) {
+ "-" +
+ maxOf(0.0, currentTrackDuration - sliderPosition)
+ .toInt()
+ .formatTime(true)
+ } else {
+ currentTrackDuration.toInt().formatTime(true)
+ },
style = typography.bodySmall,
color = colorScheme.onBackground.copy(alpha = 0.6f),
+ modifier = Modifier.clickable { showRemainingTime = !showRemainingTime },
)
}
}
@@ -130,80 +142,82 @@ fun TrackControlComposable(
modifier =
Modifier
.fillMaxWidth()
- .padding(top = 6.dp)
+ .padding(top = 48.dp)
.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
- IconButton(
- onClick = {
- withHaptic(view) { viewModel.previousTrack() }
- },
- enabled = true,
- ) {
- Icon(
- imageVector = Icons.Rounded.SkipPrevious,
- contentDescription = "Previous Track",
- tint = colorScheme.onBackground,
- modifier = Modifier.size(36.dp),
- )
+ if (showNavButtons) {
+ IconButton(
+ onClick = {
+ withHaptic(view) { viewModel.previousTrack() }
+ },
+ enabled = true,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.SkipPrevious,
+ contentDescription = "Previous Track",
+ tint = colorScheme.onBackground,
+ modifier = Modifier.size(36.dp),
+ )
+ }
}
- IconButton(
+ SeekButton(
+ duration = seekTime.rewind.seconds,
+ isForward = false,
onClick = { withHaptic(view) { viewModel.rewind() } },
- ) {
- Icon(
- imageVector = provideReplayIcon(seekTime),
- contentDescription = "Rewind",
- tint = colorScheme.onBackground,
- modifier = Modifier.size(48.dp),
- )
- }
+ )
IconButton(
onClick = { withHaptic(view) { viewModel.togglePlayPause() } },
modifier = Modifier.size(72.dp),
) {
- Icon(
- imageVector = if (isPlaying) Icons.Rounded.PauseCircleFilled else Icons.Rounded.PlayCircleFilled,
- contentDescription = "Play / Pause",
- tint = colorScheme.primary,
+ Surface(
+ shape = androidx.compose.foundation.shape.CircleShape,
+ color = colorScheme.primary,
modifier = Modifier.fillMaxSize(),
- )
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
+ contentDescription = "Play / Pause",
+ tint = colorScheme.onPrimary,
+ modifier = Modifier.size(48.dp),
+ )
+ }
+ }
}
- IconButton(
+ SeekButton(
+ duration = seekTime.forward.seconds,
+ isForward = true,
onClick = { withHaptic(view) { viewModel.forward() } },
- ) {
- Icon(
- imageVector = provideForwardIcon(seekTime),
- contentDescription = "Forward",
- tint = colorScheme.onBackground,
- modifier = Modifier.size(48.dp),
- )
- }
+ )
- IconButton(
- onClick = {
- if (currentTrackIndex < chapters.size - 1) {
- withHaptic(view) { viewModel.nextTrack() }
- }
- },
- enabled = currentTrackIndex < chapters.size - 1,
- ) {
- Icon(
- imageVector = Icons.Rounded.SkipNext,
- contentDescription = "Next Track",
- tint =
+ if (showNavButtons) {
+ IconButton(
+ onClick = {
if (currentTrackIndex < chapters.size - 1) {
- colorScheme.onBackground
- } else {
- colorScheme.onBackground.copy(
- alpha = 0.3f,
- )
- },
- modifier = Modifier.size(36.dp),
- )
+ withHaptic(view) { viewModel.nextTrack() }
+ }
+ },
+ enabled = currentTrackIndex < chapters.size - 1,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.SkipNext,
+ contentDescription = "Next Track",
+ tint =
+ if (currentTrackIndex < chapters.size - 1) {
+ colorScheme.onBackground
+ } else {
+ colorScheme.onBackground.copy(
+ alpha = 0.3f,
+ )
+ },
+ modifier = Modifier.size(36.dp),
+ )
+ }
}
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt
index 05b1da7fd..9d3204287 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt
@@ -1,6 +1,9 @@
package org.grakovne.lissen.ui.screens.player.composable
import android.content.Context
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
@@ -11,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -19,10 +23,12 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -36,12 +42,15 @@ import org.grakovne.lissen.ui.components.AsyncShimmeringImage
import org.grakovne.lissen.viewmodel.LibraryViewModel
import org.grakovne.lissen.viewmodel.PlayerViewModel
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TrackDetailsComposable(
libraryViewModel: LibraryViewModel,
viewModel: PlayerViewModel,
modifier: Modifier = Modifier,
imageLoader: ImageLoader,
+ onTitleClick: () -> Unit = {},
+ onChapterClick: () -> Unit = {},
) {
val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0)
val book by viewModel.book.observeAsState()
@@ -59,7 +68,7 @@ fun TrackDetailsComposable(
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
- val maxImageHeight = screenHeight * 0.33f
+ val maxImageHeight = screenHeight * 0.40f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -74,16 +83,18 @@ fun TrackDetailsComposable(
Modifier
.heightIn(max = maxImageHeight)
.aspectRatio(1f)
- .clip(RoundedCornerShape(8.dp)),
+ .clip(RoundedCornerShape(8.dp))
+ .shadow(12.dp, RoundedCornerShape(8.dp)),
error = painterResource(R.drawable.cover_fallback),
)
- Spacer(modifier = Modifier.height(12.dp))
+ Spacer(modifier = Modifier.height(24.dp))
+ // Book Title
Text(
text = book?.title.orEmpty(),
- style = typography.headlineSmall,
- fontWeight = FontWeight.SemiBold,
+ style = typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
color = colorScheme.onBackground,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
@@ -91,48 +102,83 @@ fun TrackDetailsComposable(
modifier =
Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp),
+ .padding(horizontal = 24.dp)
+ .clickable(onClick = onTitleClick),
)
- Spacer(modifier = Modifier.height(2.dp))
+ Spacer(modifier = Modifier.height(4.dp))
- book
- ?.subtitle
- ?.takeIf { it.isNotBlank() }
- ?.let {
+ // Author
+ book?.author?.takeIf { it.isNotBlank() }?.let { author ->
+ Text(
+ text = stringResource(R.string.book_detail_author_pattern, author),
+ style = typography.titleSmall,
+ color = colorScheme.onBackground.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+
+ // Chapter Title & Index
+ val chapterTitle = book?.chapters?.getOrNull(currentTrackIndex)?.title
+ val chapterIndexString =
+ provideChapterIndexTitle(
+ currentTrackIndex = currentTrackIndex,
+ book = book,
+ libraryType = libraryViewModel.fetchPreferredLibraryType(),
+ context = context,
+ )
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onChapterClick),
+ ) {
+ if (!chapterTitle.isNullOrBlank()) {
Text(
- text = it,
+ text = chapterTitle,
style = typography.bodyMedium,
- color = colorScheme.onBackground.copy(alpha = 0.6f),
+ color = colorScheme.onBackground.copy(alpha = 0.9f),
textAlign = TextAlign.Center,
+ maxLines = 2,
overflow = TextOverflow.Ellipsis,
- maxLines = 1,
modifier =
Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
+ .fillMaxWidth(),
)
- Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = chapterIndexString,
+ style = typography.bodySmall,
+ textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
+ color = colorScheme.onBackground.copy(alpha = 0.5f),
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ } else {
+ Text(
+ text = chapterIndexString,
+ style = typography.bodyMedium,
+ textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
+ color = colorScheme.onBackground.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ )
}
-
- Text(
- text =
- provideChapterNumberTitle(
- currentTrackIndex = currentTrackIndex,
- book = book,
- libraryType = libraryViewModel.fetchPreferredLibraryType(),
- context = context,
- ),
- style = typography.bodyMedium,
- color = colorScheme.onBackground.copy(alpha = 0.6f),
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth(),
- )
+ }
}
}
-private fun provideChapterNumberTitle(
+private fun provideChapterIndexTitle(
currentTrackIndex: Int,
book: DetailedItem?,
libraryType: LibraryType,
@@ -160,3 +206,36 @@ private fun provideChapterNumberTitle(
book?.chapters?.size ?: "?",
)
}
+
+private fun provideChapterNumberTitle(
+ currentTrackIndex: Int,
+ book: DetailedItem?,
+ libraryType: LibraryType,
+ context: Context,
+): String =
+ when (libraryType) {
+ LibraryType.LIBRARY -> {
+ val part =
+ context.getString(
+ R.string.player_screen_now_playing_title_chapter_of,
+ currentTrackIndex + 1,
+ book?.chapters?.size ?: "?",
+ )
+ val chapterTitle = book?.chapters?.getOrNull(currentTrackIndex)?.title
+ if (chapterTitle.isNullOrBlank()) part else "$chapterTitle ($part)"
+ }
+
+ LibraryType.PODCAST ->
+ context.getString(
+ R.string.player_screen_now_playing_title_podcast_of,
+ currentTrackIndex + 1,
+ book?.chapters?.size ?: "?",
+ )
+
+ LibraryType.UNKNOWN ->
+ context.getString(
+ R.string.player_screen_now_playing_title_item_of,
+ currentTrackIndex + 1,
+ book?.chapters?.size ?: "?",
+ )
+ }
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/common/SeekButton.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/common/SeekButton.kt
new file mode 100644
index 000000000..b6418205e
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/common/SeekButton.kt
@@ -0,0 +1,61 @@
+package org.grakovne.lissen.ui.screens.player.composable.common
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Replay
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.grakovne.lissen.R
+
+@Composable
+fun SeekButton(
+ duration: Int,
+ isForward: Boolean,
+ onClick: () -> Unit,
+) {
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier.size(48.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = Icons.Rounded.Replay,
+ contentDescription =
+ when (isForward) {
+ true -> stringResource(R.string.seek_forward_description, duration)
+ false -> stringResource(R.string.seek_rewind_description, duration)
+ },
+ tint = MaterialTheme.colorScheme.onBackground,
+ modifier =
+ Modifier
+ .size(48.dp)
+ .graphicsLayer {
+ if (isForward) {
+ scaleX = -1f
+ }
+ },
+ )
+
+ Text(
+ text = duration.toString(),
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.offset(y = 2.dp),
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/placeholder/NavigationBarPlaceholderComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/placeholder/NavigationBarPlaceholderComposable.kt
deleted file mode 100644
index f03e94841..000000000
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/placeholder/NavigationBarPlaceholderComposable.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-package org.grakovne.lissen.ui.screens.player.composable.placeholder
-
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.QueueMusic
-import androidx.compose.material.icons.outlined.CloudDownload
-import androidx.compose.material.icons.outlined.SlowMotionVideo
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme.colorScheme
-import androidx.compose.material3.MaterialTheme.typography
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.NavigationBarItemDefaults
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import org.grakovne.lissen.R
-import org.grakovne.lissen.lib.domain.LibraryType
-import org.grakovne.lissen.ui.icons.TimerPlay
-
-@Composable
-fun NavigationBarPlaceholderComposable(
- modifier: Modifier = Modifier,
- libraryType: LibraryType,
-) {
- Surface(
- shadowElevation = 4.dp,
- modifier = modifier.height(64.dp),
- ) {
- NavigationBar(
- containerColor = Color.Transparent,
- contentColor = colorScheme.onBackground,
- modifier = Modifier.fillMaxWidth(),
- ) {
- val iconSize = 24.dp
- val labelStyle = typography.labelSmall.copy(fontSize = 10.sp)
-
- NavigationBarItem(
- icon = {
- Icon(
- Icons.AutoMirrored.Rounded.QueueMusic,
- contentDescription =
- when (libraryType) {
- LibraryType.LIBRARY -> stringResource(R.string.player_screen_chapter_list_navigation_library)
- LibraryType.PODCAST -> stringResource(R.string.player_screen_chapter_list_navigation_podcast)
- LibraryType.UNKNOWN -> stringResource(R.string.player_screen_chapter_list_navigation_items)
- },
- modifier = Modifier.size(iconSize),
- )
- },
- label = {
- Text(
- text =
- when (libraryType) {
- LibraryType.LIBRARY -> stringResource(R.string.player_screen_chapter_list_navigation_library)
- LibraryType.PODCAST -> stringResource(R.string.player_screen_chapter_list_navigation_podcast)
- LibraryType.UNKNOWN -> stringResource(R.string.player_screen_chapter_list_navigation_items)
- },
- style = labelStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- selected = false,
- onClick = { },
- colors =
- NavigationBarItemDefaults.colors(
- selectedIconColor = colorScheme.primary,
- indicatorColor = colorScheme.surfaceContainer,
- ),
- )
-
- NavigationBarItem(
- icon = {
- Icon(
- imageVector = Icons.Outlined.CloudDownload,
- contentDescription = stringResource(R.string.player_screen_downloads_navigation),
- modifier = Modifier.size(iconSize),
- )
- },
- label = {
- Text(
- text = stringResource(R.string.player_screen_downloads_navigation),
- style = labelStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- selected = false,
- onClick = {},
- colors =
- NavigationBarItemDefaults.colors(
- selectedIconColor = colorScheme.primary,
- indicatorColor = colorScheme.surfaceContainer,
- ),
- )
-
- NavigationBarItem(
- icon = {
- Icon(
- Icons.Outlined.SlowMotionVideo,
- contentDescription = stringResource(R.string.player_screen_playback_speed_navigation),
- modifier = Modifier.size(iconSize),
- )
- },
- label = {
- Text(
- text = stringResource(R.string.player_screen_playback_speed_navigation),
- style = labelStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- selected = false,
- onClick = { },
- enabled = true,
- colors =
- NavigationBarItemDefaults.colors(
- selectedIconColor = colorScheme.primary,
- indicatorColor = colorScheme.surfaceContainer,
- ),
- )
-
- NavigationBarItem(
- icon = {
- Icon(
- TimerPlay,
- contentDescription = stringResource(R.string.player_screen_timer_navigation),
- modifier = Modifier.size(iconSize),
- )
- },
- label = {
- Text(
- text = stringResource(R.string.player_screen_timer_navigation),
- style = labelStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- selected = false,
- onClick = { },
- colors =
- NavigationBarItemDefaults.colors(
- selectedIconColor = colorScheme.primary,
- indicatorColor = colorScheme.surfaceContainer,
- ),
- )
- }
- }
-}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt
index cc2e2721b..7224c2595 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt
@@ -48,7 +48,7 @@ fun TrackDetailsPlaceholderComposable(
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.shimmer()
- .background(Color.Gray),
+ .background(colorScheme.surfaceVariant),
)
Spacer(modifier = Modifier.height(12.dp))
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt
index 86f478309..0edd5e143 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt
@@ -100,6 +100,12 @@ fun SettingsScreen(
LibraryOrderingSettingsComposable(viewModel)
+ AdvancedSettingsNavigationItemComposable(
+ title = stringResource(R.string.playback_settings_title),
+ description = stringResource(R.string.playback_settings_description),
+ onclick = { navController.showPlaybackSettings() },
+ )
+
AdvancedSettingsNavigationItemComposable(
title = stringResource(R.string.download_settings_title),
description = stringResource(R.string.download_settings_description),
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt
index b4ebaba76..46ada8dfe 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt
@@ -33,7 +33,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import kotlinx.coroutines.launch
import org.grakovne.lissen.R
import org.grakovne.lissen.ui.navigation.AppNavigationService
-import org.grakovne.lissen.ui.screens.settings.composable.PlaybackVolumeBoostSettingsComposable
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
import org.grakovne.lissen.viewmodel.CachingModelView
import org.grakovne.lissen.viewmodel.SettingsViewModel
@@ -93,14 +92,6 @@ fun AdvancedSettingsComposable(
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- PlaybackVolumeBoostSettingsComposable(viewModel)
-
- AdvancedSettingsNavigationItemComposable(
- title = stringResource(R.string.settings_screen_seek_time_title),
- description = stringResource(R.string.settings_screen_seek_time_hint),
- onclick = { navController.showSeekSettings() },
- )
-
AdvancedSettingsNavigationItemComposable(
title = stringResource(R.string.settings_screen_custom_headers_title),
description = stringResource(R.string.settings_screen_custom_header_hint),
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItemComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItemComposable.kt
index 77d9d98fc..5e1681382 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItemComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItemComposable.kt
@@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
+import org.grakovne.lissen.ui.effects.WindowBlurEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -37,11 +38,15 @@ fun CommonSettingsItemComposable(
onDismissRequest: () -> Unit,
onItemSelected: (CommonSettingsItem) -> Unit,
selectedImage: ImageVector = Icons.Outlined.Check,
+ title: String? = null,
) {
var activeItem by remember { mutableStateOf(selectedItem) }
+ WindowBlurEffect()
+
ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.background,
+ scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
onDismissRequest = onDismissRequest,
content = {
Column(
@@ -50,8 +55,17 @@ fun CommonSettingsItemComposable(
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(horizontal = 16.dp),
+ horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
) {
- Spacer(modifier = Modifier.height(8.dp))
+ if (title != null) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ } else {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
LazyColumn(modifier = Modifier.fillMaxWidth()) {
itemsIndexed(items) { index, item ->
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.kt
index d869879f7..240243f28 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.kt
@@ -7,13 +7,15 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import org.grakovne.lissen.BuildConfig
+import org.grakovne.lissen.R
@Composable
fun LicenseFooterComposable() {
@@ -32,26 +34,39 @@ fun LicenseFooterComposable() {
modifier =
Modifier
.fillMaxWidth()
- .padding(top = 16.dp)
- .align(Alignment.CenterHorizontally),
- text = "Lissen ${BuildConfig.VERSION_NAME}",
+ .padding(top = 16.dp),
+ text =
+ stringResource(
+ R.string.settings_screen_footer_app_name_pattern,
+ stringResource(R.string.branding_name),
+ BuildConfig.VERSION_NAME,
+ ),
style =
TextStyle(
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
+ fontSize = 12.sp,
+ color = colorScheme.onSurface.copy(alpha = 0.6f),
),
)
+
Text(
modifier =
Modifier
.fillMaxWidth()
- .padding(top = 8.dp)
- .align(Alignment.CenterHorizontally),
- text = "© 2024-2026 Max Grakov. MIT License",
+ .padding(top = 4.dp),
+ text =
+ "${stringResource(R.string.settings_screen_footer_copyright_original)} • ${
+ stringResource(
+ R.string.settings_screen_footer_copyright_fork,
+ )
+ } • ${stringResource(R.string.settings_screen_footer_license)}",
style =
TextStyle(
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
+ fontSize = 10.sp,
+ color = colorScheme.onSurface.copy(alpha = 0.4f),
),
)
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/PlaybackSmartRewindSettingsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/PlaybackSmartRewindSettingsComposable.kt
new file mode 100644
index 000000000..24634775d
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/PlaybackSmartRewindSettingsComposable.kt
@@ -0,0 +1,146 @@
+package org.grakovne.lissen.ui.screens.settings.composable
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+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.alpha
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.grakovne.lissen.R
+import org.grakovne.lissen.lib.domain.SmartRewindDuration
+import org.grakovne.lissen.lib.domain.SmartRewindInactivityThreshold
+import org.grakovne.lissen.viewmodel.SettingsViewModel
+
+@Composable
+fun PlaybackSmartRewindSettingsComposable(viewModel: SettingsViewModel) {
+ val smartRewindEnabled by viewModel.smartRewindEnabled.observeAsState(false)
+ val smartRewindThreshold by viewModel.smartRewindThreshold.observeAsState(SmartRewindInactivityThreshold.ONE_HOUR)
+ val smartRewindDuration by viewModel.smartRewindDuration.observeAsState(SmartRewindDuration.ONE_MINUTE)
+
+ var thresholdExpanded by remember { mutableStateOf(false) }
+ var durationExpanded by remember { mutableStateOf(false) }
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ // 1. Toggle
+ SettingsToggleItem(
+ title = stringResource(R.string.smart_rewind_title),
+ description = stringResource(R.string.smart_rewind_subtitle),
+ initialState = smartRewindEnabled,
+ onCheckedChange = { viewModel.preferSmartRewindEnabled(it) },
+ )
+
+ // 2. Threshold Dropdown
+ SmartRewindDropdownItem(
+ title = stringResource(R.string.smart_rewind_threshold_title),
+ value = getThresholdLabel(smartRewindThreshold),
+ enabled = smartRewindEnabled,
+ onClick = { thresholdExpanded = true },
+ )
+
+ // 3. Duration Dropdown
+ SmartRewindDropdownItem(
+ title = stringResource(R.string.smart_rewind_duration_title),
+ value = getDurationLabel(smartRewindDuration),
+ enabled = smartRewindEnabled,
+ onClick = { durationExpanded = true },
+ )
+ }
+
+ // Threshold Bottom Sheet
+ if (thresholdExpanded) {
+ CommonSettingsItemComposable(
+ title = stringResource(R.string.smart_rewind_threshold_title),
+ items =
+ SmartRewindInactivityThreshold.entries.map {
+ CommonSettingsItem(it.name, getThresholdLabel(it), null)
+ },
+ selectedItem = CommonSettingsItem(smartRewindThreshold.name, getThresholdLabel(smartRewindThreshold), null),
+ onDismissRequest = { thresholdExpanded = false },
+ onItemSelected = { item ->
+ SmartRewindInactivityThreshold.values().find { it.name == item.id }?.let {
+ viewModel.preferSmartRewindThreshold(it)
+ }
+ thresholdExpanded = false
+ },
+ )
+ }
+
+ // Duration Bottom Sheet
+ if (durationExpanded) {
+ CommonSettingsItemComposable(
+ title = stringResource(R.string.smart_rewind_duration_title),
+ items =
+ SmartRewindDuration.entries.map {
+ CommonSettingsItem(it.name, getDurationLabel(it), null)
+ },
+ selectedItem = CommonSettingsItem(smartRewindDuration.name, getDurationLabel(smartRewindDuration), null),
+ onDismissRequest = { durationExpanded = false },
+ onItemSelected = { item ->
+ SmartRewindDuration.values().find { it.name == item.id }?.let {
+ viewModel.preferSmartRewindDuration(it)
+ }
+ durationExpanded = false
+ },
+ )
+ }
+}
+
+@Composable
+private fun getThresholdLabel(threshold: SmartRewindInactivityThreshold): String =
+ when (threshold) {
+ SmartRewindInactivityThreshold.THIRTY_MINUTES -> stringResource(R.string.smart_rewind_threshold_30_min)
+ SmartRewindInactivityThreshold.ONE_HOUR -> stringResource(R.string.smart_rewind_threshold_1_hour)
+ SmartRewindInactivityThreshold.ONE_DAY -> stringResource(R.string.smart_rewind_threshold_1_day)
+ }
+
+@Composable
+private fun getDurationLabel(duration: SmartRewindDuration): String =
+ when (duration) {
+ SmartRewindDuration.THIRTY_SECONDS -> stringResource(R.string.smart_rewind_duration_30_sec)
+ SmartRewindDuration.ONE_MINUTE -> stringResource(R.string.smart_rewind_duration_1_min)
+ SmartRewindDuration.FIVE_MINUTES -> stringResource(R.string.smart_rewind_duration_5_min)
+ }
+
+@Composable
+private fun SmartRewindDropdownItem(
+ title: String,
+ value: String,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .let { if (enabled) it.clickable { onClick() } else it }
+ .padding(horizontal = 24.dp, vertical = 12.dp)
+ .alpha(if (enabled) 1f else 0.5f),
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
+ modifier = Modifier.padding(bottom = 4.dp),
+ color = colorScheme.onSurface,
+ )
+ Text(
+ text = value,
+ style = typography.bodyMedium,
+ color = colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/playback/PlaybackSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/playback/PlaybackSettingsScreen.kt
new file mode 100644
index 000000000..bc1389fe1
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/playback/PlaybackSettingsScreen.kt
@@ -0,0 +1,119 @@
+package org.grakovne.lissen.ui.screens.settings.playback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import org.grakovne.lissen.R
+import org.grakovne.lissen.ui.navigation.AppNavigationService
+import org.grakovne.lissen.ui.screens.settings.advanced.AdvancedSettingsNavigationItemComposable
+import org.grakovne.lissen.ui.screens.settings.composable.PlaybackVolumeBoostSettingsComposable
+import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
+import org.grakovne.lissen.viewmodel.SettingsViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PlaybackSettingsScreen(
+ onBack: () -> Unit,
+ navController: AppNavigationService,
+) {
+ val viewModel: SettingsViewModel = hiltViewModel()
+ val showPlayerNavButtons by viewModel.showPlayerNavButtons.observeAsState(true)
+ val shakeToResetTimer by viewModel.shakeToResetTimer.observeAsState(false)
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.playback_preferences),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { onBack() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ },
+ )
+ },
+ modifier =
+ Modifier
+ .systemBarsPadding()
+ .fillMaxHeight(),
+ content = { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ SettingsToggleItem(
+ title = stringResource(R.string.playback_next_previous_title),
+ description = stringResource(R.string.playback_next_previous_description),
+ initialState = showPlayerNavButtons,
+ onCheckedChange = { viewModel.preferShowPlayerNavButtons(it) },
+ )
+
+ SettingsToggleItem(
+ title = stringResource(R.string.playback_shake_reset_title),
+ description = stringResource(R.string.playback_shake_reset_description),
+ initialState = shakeToResetTimer,
+ onCheckedChange = { viewModel.preferShakeToResetTimer(it) },
+ )
+
+ AdvancedSettingsNavigationItemComposable(
+ title = stringResource(R.string.smart_rewind_title),
+ description = stringResource(R.string.smart_rewind_subtitle),
+ onclick = { navController.showSmartRewindSettings() },
+ )
+
+ PlaybackVolumeBoostSettingsComposable(viewModel)
+
+ AdvancedSettingsNavigationItemComposable(
+ title = stringResource(R.string.settings_screen_seek_time_title),
+ description = stringResource(R.string.settings_screen_seek_time_hint),
+ onclick = { navController.showSeekSettings() },
+ )
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/playback/SmartRewindSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/playback/SmartRewindSettingsScreen.kt
new file mode 100644
index 000000000..4d370653a
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/playback/SmartRewindSettingsScreen.kt
@@ -0,0 +1,82 @@
+package org.grakovne.lissen.ui.screens.settings.playback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import org.grakovne.lissen.R
+import org.grakovne.lissen.ui.screens.settings.composable.PlaybackSmartRewindSettingsComposable
+import org.grakovne.lissen.viewmodel.SettingsViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SmartRewindSettingsScreen(onBack: () -> Unit) {
+ val viewModel: SettingsViewModel = hiltViewModel()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.smart_rewind_title),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { onBack() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ },
+ )
+ },
+ modifier =
+ Modifier
+ .systemBarsPadding()
+ .fillMaxHeight(),
+ content = { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ PlaybackSmartRewindSettingsComposable(viewModel)
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt
index d1cde369b..8e01ea36a 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt
@@ -8,3 +8,6 @@ val Dark = Color(0xFF1C1B1F)
val Black = Color(0xFF000000)
val LightBackground = Color(0xFFFAFAFA)
val MediumBackground = Color(0xFFDADADA)
+
+val SurfaceContainerLight = Color(0xFFEEEEEE)
+val TertiaryContainerDark = Color(0xFF1A1A1A)
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Spacing.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Spacing.kt
new file mode 100644
index 000000000..cd05b3da1
--- /dev/null
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Spacing.kt
@@ -0,0 +1,11 @@
+package org.grakovne.lissen.ui.theme
+
+import androidx.compose.ui.unit.dp
+
+object Spacing {
+ val xs = 4.dp
+ val sm = 8.dp
+ val md = 16.dp
+ val lg = 20.dp
+ val xl = 32.dp
+}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Theme.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Theme.kt
index 217f05abd..0f73bdb52 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Theme.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Theme.kt
@@ -20,13 +20,15 @@ private val LightColorScheme =
tertiaryContainer = LightBackground,
background = LightBackground,
surface = LightBackground,
- surfaceContainer = Color(0xFFEEEEEE),
+ surfaceContainer = SurfaceContainerLight,
+ onPrimary = Color.White,
)
private val DarkColorScheme =
darkColorScheme(
primary = FoxOrangeDimmed,
- tertiaryContainer = Color(0xFF1A1A1A),
+ tertiaryContainer = TertiaryContainerDark,
+ onPrimary = Color.White,
)
private val BlackColorScheme =
@@ -35,6 +37,7 @@ private val BlackColorScheme =
background = Black,
surface = Black,
tertiaryContainer = Black,
+ onPrimary = Color.White,
)
@Composable
diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt
index c186b547e..fa55dcef0 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt
@@ -149,6 +149,8 @@ class CachingModelView
chapterId: String,
): LiveData = contentCachingManager.hasMetadataCached(bookId, chapterId)
+ fun hasDownloadedChapters(bookId: String): LiveData = contentCachingManager.hasDownloadedChapters(bookId)
+
fun fetchCachedItems() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt
index 977f1a2f0..2a3efa097 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt
@@ -14,19 +14,26 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import org.grakovne.lissen.content.LissenMediaProvider
+import org.grakovne.lissen.common.LibraryOrderingConfiguration
+import org.grakovne.lissen.common.NetworkService
+import org.grakovne.lissen.content.BookRepository
import org.grakovne.lissen.lib.domain.Book
import org.grakovne.lissen.lib.domain.LibraryType
import org.grakovne.lissen.lib.domain.RecentBook
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
import org.grakovne.lissen.ui.screens.library.paging.LibraryDefaultPagingSource
import org.grakovne.lissen.ui.screens.library.paging.LibrarySearchPagingSource
+import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@@ -34,8 +41,9 @@ import javax.inject.Inject
class LibraryViewModel
@Inject
constructor(
- private val mediaChannel: LissenMediaProvider,
+ private val bookRepository: BookRepository,
private val preferences: LissenSharedPreferences,
+ private val networkService: NetworkService,
) : ViewModel() {
private val _recentBooks = MutableLiveData>(emptyList())
val recentBooks: LiveData> = _recentBooks
@@ -48,7 +56,7 @@ class LibraryViewModel
private val _searchToken = MutableStateFlow(EMPTY_SEARCH)
- private var defaultPagingSource: PagingSource? = null
+ private val defaultPagingSource = MutableStateFlow?>(null)
private var searchPagingSource: PagingSource? = null
private val _totalCount = MutableLiveData()
@@ -61,6 +69,77 @@ class LibraryViewModel
prefetchDistance = PAGE_SIZE,
)
+ private val downloadedOnlyFlow =
+ combine(
+ networkService.isServerAvailable,
+ preferences.forceCacheFlow,
+ ) { isServerAvailable, isForceCache ->
+ !isServerAvailable || isForceCache
+ }
+
+ private var currentLibraryId = ""
+ private var currentOrdering = LibraryOrderingConfiguration.default
+ private var localCacheUpdatedAt = 0L
+
+ fun checkRefreshNeeded(
+ itemCount: Int,
+ latestLocalUpdate: Long?,
+ isLocalCacheUsing: Boolean,
+ ) {
+ val emptyContent = itemCount == 0
+ val libraryChanged = currentLibraryId != (preferences.getPreferredLibrary()?.id ?: "")
+ val orderingChanged = currentOrdering != preferences.getLibraryOrdering()
+ val localCacheUpdated = latestLocalUpdate?.let { it > localCacheUpdatedAt } ?: true
+
+ if (emptyContent || libraryChanged || orderingChanged || (isLocalCacheUsing && localCacheUpdated)) {
+ refreshRecentListening()
+ refreshLibrary()
+
+ currentLibraryId = preferences.getPreferredLibrary()?.id ?: ""
+ currentOrdering = preferences.getLibraryOrdering()
+ localCacheUpdatedAt = latestLocalUpdate ?: 0L
+ }
+ }
+
+ init {
+ viewModelScope.launch {
+ downloadedOnlyFlow.collect { refreshRecentListening() }
+ }
+
+ viewModelScope.launch {
+ combine(
+ preferences.preferredLibraryIdFlow,
+ downloadedOnlyFlow,
+ ) { libraryId, downloadedOnly ->
+ Pair(libraryId, downloadedOnly)
+ }.flatMapLatest { (libraryId, _) ->
+ // When downloadedOnly changes, recent books flow in CachedBookRepository
+ // (which BookRepository delegates to) handles sorting/filtering if needed.
+ // Note: BookRepository.fetchRecentListenedBooksFlow checks isOffline internally one-shot,
+ // but we want it to be reactive.
+ // For now, let's just trigger the flow. Ideally, BookRepository should handle the isOffline check reactively too.
+ // But re-collecting here when downloadedOnlyFlow emits will re-call fetchRecentListenedBooksFlow,
+ // effectively re-checking the isOffline state.
+ bookRepository.fetchRecentListenedBooksFlow(libraryId ?: "")
+ }.collect {
+ _recentBooks.postValue(it)
+ }
+ }
+
+ viewModelScope.launch {
+ networkService
+ .isServerAvailable
+ .collect { isAvailable ->
+ if (isAvailable) {
+ Timber.d("Server is reachable. Triggering repository sync.")
+ bookRepository.syncRepositories()
+ refreshRecentListening()
+ refreshLibrary()
+ }
+ }
+ }
+ }
+
fun getPager(isSearchRequested: Boolean) =
when (isSearchRequested) {
true -> searchPager
@@ -80,7 +159,7 @@ class LibraryViewModel
val source =
LibrarySearchPagingSource(
preferences = preferences,
- mediaChannel = mediaChannel,
+ bookRepository = bookRepository,
searchToken = token,
limit = PAGE_SEARCH_SIZE,
) { _totalCount.postValue(it) }
@@ -91,16 +170,39 @@ class LibraryViewModel
).flow
}.cachedIn(viewModelScope)
- private val libraryPager: Flow> by lazy {
- Pager(
- config = pageConfig,
- pagingSourceFactory = {
- val source = LibraryDefaultPagingSource(preferences, mediaChannel) { _totalCount.postValue(it) }
- defaultPagingSource = source
+ private val libraryPager: Flow> =
+ combine(
+ preferences.preferredLibraryIdFlow,
+ downloadedOnlyFlow,
+ ) { libraryId, downloadedOnly ->
+ Pair(libraryId, downloadedOnly)
+ }.onEach { (libraryId, downloadedOnly) ->
+ if (!downloadedOnly && libraryId != null) {
+ syncLibrary(libraryId)
+ }
+ }.flatMapLatest { (libraryId, downloadedOnly) ->
+ Pager(
+ config = pageConfig,
+ pagingSourceFactory = {
+ val source =
+ LibraryDefaultPagingSource(
+ preferences = preferences,
+ bookRepository = bookRepository,
+ downloadedOnly = downloadedOnly,
+ ) { _totalCount.postValue(it) }
+ defaultPagingSource.tryEmit(source)
+
+ source
+ },
+ ).flow
+ }.cachedIn(viewModelScope)
- source
- },
- ).flow.cachedIn(viewModelScope)
+ private fun syncLibrary(libraryId: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ bookRepository.syncRepositories()
+ refreshRecentListening()
+ defaultPagingSource.value?.invalidate()
+ }
}
fun requestSearch() {
@@ -140,7 +242,7 @@ class LibraryViewModel
withContext(Dispatchers.IO) {
when (searchRequested.value) {
true -> searchPagingSource?.invalidate()
- else -> defaultPagingSource?.invalidate()
+ else -> defaultPagingSource.value?.invalidate()
}
}
}
@@ -156,17 +258,8 @@ class LibraryViewModel
}
viewModelScope.launch {
- mediaChannel
- .fetchRecentListenedBooks(preferredLibrary)
- .fold(
- onSuccess = {
- _recentBooks.postValue(it)
- _recentBookUpdating.postValue(false)
- },
- onFailure = {
- _recentBookUpdating.postValue(false)
- },
- )
+ bookRepository.fetchRecentListenedBooks(preferredLibrary)
+ _recentBookUpdating.postValue(false)
}
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt
index 3a1821ab4..3a4f860ae 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt
@@ -7,7 +7,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
+import org.grakovne.lissen.common.NetworkService
import org.grakovne.lissen.lib.domain.DetailedItem
import org.grakovne.lissen.lib.domain.PlayingChapter
import org.grakovne.lissen.lib.domain.TimerOption
@@ -22,7 +25,18 @@ class PlayerViewModel
constructor(
private val mediaRepository: MediaRepository,
private val preferences: LissenSharedPreferences,
+ private val networkService: NetworkService,
) : ViewModel() {
+ val isOnline: Flow =
+ combine(
+ networkService.isServerAvailable,
+ preferences.forceCacheFlow,
+ ) { isServerAvailable, isForceCache ->
+ isServerAvailable && !isForceCache
+ }
+
+ fun getBookFlow(bookId: String): Flow = mediaRepository.getBookFlow(bookId)
+
val book: LiveData = mediaRepository.playingBook
val currentChapterIndex: LiveData = mediaRepository.currentChapterIndex
@@ -40,6 +54,7 @@ class PlayerViewModel
val isPlaybackReady: LiveData = mediaRepository.isPlaybackReady
val playbackSpeed: LiveData = mediaRepository.playbackSpeed
val preparingError: LiveData = mediaRepository.mediaPreparingError
+ val preparingBookId: LiveData = mediaRepository.preparingBookId
private val _searchRequested = MutableLiveData(false)
val searchRequested: LiveData = _searchRequested
@@ -111,6 +126,7 @@ class PlayerViewModel
if (chapter.available) {
val index = book.value?.chapters?.indexOf(chapter) ?: -1
mediaRepository.setChapter(index)
+ mediaRepository.play()
}
}
@@ -129,6 +145,19 @@ class PlayerViewModel
mediaRepository.prepareAndPlay(playingBook)
}
+ fun playBook(
+ book: DetailedItem,
+ chapterIndex: Int? = null,
+ ) {
+ mediaRepository.prepareAndPlay(book, chapterIndex)
+ }
+
+ fun fetchBook(bookId: String) {
+ viewModelScope.launch {
+ mediaRepository.fetchBook(bookId)
+ }
+ }
+
companion object {
private const val EMPTY_SEARCH = ""
}
diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt
index 4a1c20b73..2a8357ac9 100644
--- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt
@@ -3,6 +3,7 @@ package org.grakovne.lissen.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
@@ -17,6 +18,8 @@ import org.grakovne.lissen.lib.domain.DownloadOption
import org.grakovne.lissen.lib.domain.Library
import org.grakovne.lissen.lib.domain.LibraryType
import org.grakovne.lissen.lib.domain.SeekTimeOption
+import org.grakovne.lissen.lib.domain.SmartRewindDuration
+import org.grakovne.lissen.lib.domain.SmartRewindInactivityThreshold
import org.grakovne.lissen.lib.domain.connection.LocalUrl
import org.grakovne.lissen.lib.domain.connection.LocalUrl.Companion.clean
import org.grakovne.lissen.lib.domain.connection.ServerRequestHeader
@@ -82,6 +85,14 @@ class SettingsViewModel
private val _autoDownloadDelayed = MutableLiveData(preferences.getAutoDownloadDelayed())
val autoDownloadDelayed = _autoDownloadDelayed
+ val showPlayerNavButtons = preferences.showPlayerNavButtonsFlow.asLiveData()
+
+ val shakeToResetTimer = preferences.shakeToResetTimerFlow.asLiveData()
+
+ val smartRewindEnabled = preferences.smartRewindEnabledFlow.asLiveData()
+ val smartRewindThreshold = preferences.smartRewindThresholdFlow.asLiveData()
+ val smartRewindDuration = preferences.smartRewindDurationFlow.asLiveData()
+
fun preferCrashReporting(value: Boolean) {
_crashReporting.postValue(value)
preferences.saveAcraEnabled(value)
@@ -97,6 +108,26 @@ class SettingsViewModel
preferences.saveAutoDownloadDelayed(value)
}
+ fun preferShowPlayerNavButtons(value: Boolean) {
+ preferences.saveShowPlayerNavButtons(value)
+ }
+
+ fun preferShakeToResetTimer(value: Boolean) {
+ preferences.saveShakeToResetTimer(value)
+ }
+
+ fun preferSmartRewindEnabled(value: Boolean) {
+ preferences.saveSmartRewindEnabled(value)
+ }
+
+ fun preferSmartRewindThreshold(value: SmartRewindInactivityThreshold) {
+ preferences.saveSmartRewindThreshold(value)
+ }
+
+ fun preferSmartRewindDuration(value: SmartRewindDuration) {
+ preferences.saveSmartRewindDuration(value)
+ }
+
fun logout() {
preferences.clearPreferences()
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ad183a180..b241e26f7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,6 @@
- Lissen
+ Kahani
+ Kahani
Library
Continue listening
Preferences
@@ -10,9 +11,7 @@
Password
Connect
Speed
- Chapters
- Episodes
- Items
+ Chapters
Playing chapters
Playing episodes
Playing items
@@ -128,7 +127,7 @@
Medium
High
Maximum
- Boosted volume
+ Boost volume
Time remaining
Advanced preferences
Control seeking, proxy headers, or sound
@@ -157,5 +156,54 @@
Wait a short time before starting download
Disable SSL verification
Connect to servers with any certificate
-
+ By %1$s
+ Narrated by %1$s
+ Smart Rewind
+ Rewind slightly when resuming after a break
+ If inactive for
+ Rewind by
+ 30 minutes
+ 1 hour
+ 1 day
+ 30 seconds
+ 1 minute
+ 5 minutes
+ Pause
+ Listen Again
+ Resume
+ Start Listening
+
+ Chapters
+ Items
+ Episodes
+ Published
+ Length
+ Remaining
+ Unknown
+ Unknown
+ Read more
+ See less
+
+ - %1$d hr
+ - %1$d hrs
+
+
+ - %1$d min
+ - %1$d mins
+
+ Download progress: %1$d percent
+ Forward %1$d seconds
+ Rewind %1$d seconds
+ Playback preferences
+ Player controls and sleep timer settings
+ Back
+ Next/previous buttons
+ Show next and previous track buttons in the player
+ Shake to reset sleep timer
+ Shake device to reset the sleep timer
+ Playback preferences
+ %1$s %2$s
+ © 2024–2026 Max Grakov
+ © 2026 Surjit Sahoo
+ MIT License
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index daa43333a..8c8313afd 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -1,6 +1,6 @@
-
+