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 @@ -