diff --git a/.gitignore b/.gitignore index f90828f..e53b2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ captures/ bin/ gen/ target/ +app/freeAsInBeer +app/googlePlay +app/release # android studio files .idea/ diff --git a/app/build.gradle b/app/build.gradle index 58de053..6ec584a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,17 +1,17 @@ apply plugin: "com.android.application" -apply plugin: "kotlin-android" +apply plugin: "org.jetbrains.kotlin.android" apply plugin: "kotlin-kapt" apply plugin: "kotlin-parcelize" apply plugin: "com.google.android.gms.oss-licenses-plugin" android { - compileSdkVersion 31 + compileSdkVersion 33 defaultConfig { applicationId "io.github.mattpvaughn.chronicle" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 24 - versionName "0.52.1" + minSdkVersion 31 + targetSdkVersion 33 + versionCode 26 + versionName '0.54.0' testInstrumentationRunner "io.github.mattpvaughn.chronicle.application.ChronicleTestRunner" } buildTypes { @@ -35,11 +35,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } // Shared code b/w test and androidTest: mocks "n" stuff sourceSets { @@ -58,14 +58,11 @@ android { arg("room.expandProjection", "true") } } - lintOptions { - abortOnError false - } kotlinOptions { freeCompilerArgs = ["-Xallow-result-return-type"] - freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"] - freeCompilerArgs += ["-Xopt-in=kotlin.time.ExperimentalTime"] - freeCompilerArgs += ["-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi"] + freeCompilerArgs += ["-opt-in=kotlin.RequiresOptIn"] + freeCompilerArgs += ["-opt-in=kotlin.time.ExperimentalTime"] + freeCompilerArgs += ["-opt-in=kotlinx.coroutines.InternalCoroutinesApi"] } flavorDimensions "freeAsInBeer" productFlavors { @@ -78,8 +75,14 @@ android { buildConfigField "boolean", "FREE_AS_IN_BEER", "false" } } + lint { + abortOnError false + } + namespace 'io.github.mattpvaughn.chronicle' } + + dependencies { implementation fileTree(include: ["*.jar"], dir: "libs") @@ -90,7 +93,7 @@ dependencies { implementation "com.google.android.material:material:$materialLibVersion" implementation "androidx.appcompat:appcompat:$supportlibVersion" implementation "androidx.fragment:fragment-ktx:$appCompatFragmentVersion" - implementation "androidx.recyclerview:recyclerview:1.2.0" + implementation "androidx.recyclerview:recyclerview:1.3.0" implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" // AndroidX @@ -150,7 +153,7 @@ dependencies { // Fresco - image loading - implementation 'com.facebook.fresco:fresco:2.4.0' + implementation 'com.facebook.fresco:fresco:2.6.0' implementation "com.facebook.fresco:imagepipeline-okhttp3:2.4.0" // LocalBroadcastManager @@ -162,10 +165,10 @@ dependencies { implementation "com.google.android.exoplayer:extension-mediasession:$exoplayerVersion" // ExoPlayer extensions for FLAC and OPUS file types - implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:$exoplayerVersion") { + implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:$exoplayerExtensions") { transitive = false } - implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:$exoplayerVersion"){ + implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:$exoplayerExtensions"){ transitive = false } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 877db40..0a85083 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/application/MainActivity.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/application/MainActivity.kt index 36be90c..e2ef2e4 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/application/MainActivity.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/application/MainActivity.kt @@ -111,15 +111,21 @@ class MainActivity : AppCompatActivity() { Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show() } - binding.bottomNav.setOnNavigationItemSelectedListener { + // TODO: show/hide this item on launch more performantly + viewModel.hasCollections.observe(this) { + binding.bottomNav.menu.findItem(R.id.nav_collections).isVisible = it + } + + binding.bottomNav.setOnItemSelectedListener { when (it.itemId) { R.id.nav_settings -> navigator.showSettings() R.id.nav_library -> navigator.showLibrary() + R.id.nav_collections -> navigator.showCollections() R.id.nav_home -> navigator.showHome() else -> throw NoWhenBranchMatchedException("Unknown bottom tab id: ${it.itemId}") } viewModel.minimizeCurrentlyPlaying() - return@setOnNavigationItemSelectedListener true + return@setOnItemSelectedListener true } if (savedInstanceState == null) { @@ -174,8 +180,8 @@ class MainActivity : AppCompatActivity() { this, object : GestureDetector.SimpleOnGestureListener() { override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent?, + e1: MotionEvent, + e2: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/application/MainActivityViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/application/MainActivityViewModel.kt index b4d2f92..aab7e69 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/application/MainActivityViewModel.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/application/MainActivityViewModel.kt @@ -6,6 +6,7 @@ import android.support.v4.media.session.PlaybackStateCompat.STATE_NONE import android.support.v4.media.session.PlaybackStateCompat.STATE_STOPPED import androidx.lifecycle.* import io.github.mattpvaughn.chronicle.application.MainActivityViewModel.BottomSheetState.* +import io.github.mattpvaughn.chronicle.data.local.CollectionsRepository import io.github.mattpvaughn.chronicle.data.local.IBookRepository import io.github.mattpvaughn.chronicle.data.local.ITrackRepository import io.github.mattpvaughn.chronicle.data.model.* @@ -27,6 +28,7 @@ class MainActivityViewModel( private val trackRepository: ITrackRepository, private val bookRepository: IBookRepository, private val mediaServiceConnection: MediaServiceConnection, + collectionsRepository: CollectionsRepository ) : ViewModel(), MainActivity.CurrentlyPlayingInterface { @Suppress("UNCHECKED_CAST") @@ -35,15 +37,17 @@ class MainActivityViewModel( private val trackRepository: ITrackRepository, private val bookRepository: IBookRepository, private val mediaServiceConnection: MediaServiceConnection, + private val collectionsRepository: CollectionsRepository ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { + override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(MainActivityViewModel::class.java)) { return MainActivityViewModel( loginRepo, trackRepository, bookRepository, - mediaServiceConnection + mediaServiceConnection, + collectionsRepository ) as T } else { throw IllegalArgumentException("Cannot instantiate $modelClass from MainActivityViewModel.Factory") @@ -58,7 +62,7 @@ class MainActivityViewModel( EXPANDED } - val isLoggedIn = Transformations.map(loginRepo.loginEvent) { + val isLoggedIn = loginRepo.loginEvent.map { it.peekContent() == LOGGED_IN_FULLY } @@ -72,7 +76,7 @@ class MainActivityViewModel( bookRepository.getAudiobookAsync(id) ?: EMPTY_AUDIOBOOK } - private var tracks = Transformations.switchMap(audiobookId) { id -> + private var tracks = audiobookId.switchMap { id -> if (id != NO_AUDIOBOOK_FOUND_ID) { trackRepository.getTracksForAudiobook(id) } else { @@ -84,6 +88,8 @@ class MainActivityViewModel( val errorMessage: LiveData> get() = _errorMessage + val hasCollections = collectionsRepository.hasCollections() + // Used to cache tracks.asChapterList when tracks changes private val tracksAsChaptersCache = mapAsync(tracks, viewModelScope) { it.asChapterList() @@ -111,7 +117,7 @@ class MainActivityViewModel( }.getChapterAt(_tracks.getActiveTrack().id.toLong(), currentTrackProgress).title } - val isPlaying = Transformations.map(mediaServiceConnection.playbackState) { + val isPlaying = mediaServiceConnection.playbackState.map { it.isPlaying } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/BookDatabase.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/BookDatabase.kt index 32dec77..46ebc74 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/BookDatabase.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/BookDatabase.kt @@ -24,7 +24,7 @@ fun getBookDatabase(context: Context): BookDatabase { BOOK_MIGRATION_4_5, BOOK_MIGRATION_5_6, BOOK_MIGRATION_6_7, - BOOK_MIGRATION_7_8 + BOOK_MIGRATION_7_8, ).build() } } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/CollectionsDatabase.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/CollectionsDatabase.kt new file mode 100644 index 0000000..d23c35f --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/CollectionsDatabase.kt @@ -0,0 +1,57 @@ +package io.github.mattpvaughn.chronicle.data.local + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.room.* +import io.github.mattpvaughn.chronicle.data.model.Collection + +private const val COLLECTIONS_DATABASE_NAME = "collections_db" + +private lateinit var INSTANCE: CollectionsDatabase +fun getCollectionsDatabase(context: Context): CollectionsDatabase { + synchronized(CollectionsDatabase::class.java) { + if (!::INSTANCE.isInitialized) { + INSTANCE = Room.databaseBuilder( + context.applicationContext, + CollectionsDatabase::class.java, + COLLECTIONS_DATABASE_NAME + ).addMigrations().build() + } + } + return INSTANCE +} + +@Database(entities = [Collection::class], version = 1, exportSchema = false) +abstract class CollectionsDatabase : RoomDatabase() { + abstract val collectionsDao: CollectionsDao +} + +@Dao +interface CollectionsDao { + @Query("SELECT * FROM Collection ORDER BY title") + fun getAllRows(): LiveData> + + @Query("SELECT * FROM Collection WHERE id = :id LIMIT 1") + fun getCollection(id: Int): LiveData + + @Query("SELECT * FROM Collection WHERE :collectionId = id") + suspend fun getCollectionAsync(collectionId: Int): Collection + + @Query("SELECT * FROM Collection") + fun getCollections(): List + + @Query("SELECT Count(id) FROM Collection") + fun countCollections(): LiveData + + @Query("DELETE FROM Collection") + suspend fun clear() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun update(collection: Collection) + + @Query("DELETE FROM Collection WHERE id IN (:collectionsToRemove)") + fun removeAll(collectionsToRemove: List): Int +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/CollectionsRepository.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/CollectionsRepository.kt new file mode 100644 index 0000000..f18e2ba --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/CollectionsRepository.kt @@ -0,0 +1,81 @@ +package io.github.mattpvaughn.chronicle.data.local + +import androidx.lifecycle.LiveData +import androidx.lifecycle.map +import io.github.mattpvaughn.chronicle.data.model.Collection +import io.github.mattpvaughn.chronicle.data.sources.plex.PlexMediaService +import io.github.mattpvaughn.chronicle.data.sources.plex.PlexPrefsRepo +import io.github.mattpvaughn.chronicle.data.sources.plex.model.asAudiobooks +import io.github.mattpvaughn.chronicle.data.sources.plex.model.asCollections +import kotlinx.coroutines.* +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CollectionsRepository @Inject constructor( + private val plexMediaService: PlexMediaService, + private val prefsRepo: PrefsRepo, + private val plexPrefsRepo: PlexPrefsRepo, + private val collectionsDao: CollectionsDao +) { + + // TODO: handle collections sorting! + suspend fun getChildIds(collectionId: Int): List { + return collectionsDao.getCollectionAsync(collectionId).childIds + } + + fun getCollection(id: Int): LiveData = collectionsDao.getCollection(id) + + fun getAllCollections(): LiveData> = collectionsDao.getAllRows() + + fun hasCollections(): LiveData = collectionsDao + .countCollections() + .map { it > 0 } + + suspend fun refreshCollectionsPaginated() { + prefsRepo.lastRefreshTimeStamp = System.currentTimeMillis() + val networkCollections: MutableList = mutableListOf() + withContext(Dispatchers.IO) { + try { + val libraryId = plexPrefsRepo.library?.id ?: return@withContext + var chaptersLeft = 1L + // Maximum number of pages of data we fetch. Failsafe in case of bad data from the + // server since we don't want infinite loops. This limits us to a maximum 1,000,000 + // collections for now + val maxIterations = 5000 + var i = 0 + while (chaptersLeft > 0 && i < maxIterations) { + val response = plexMediaService + .retrieveCollectionsPaginated(libraryId, i * 100) + .plexMediaContainer + chaptersLeft = response.totalSize - (response.offset + response.size) + networkCollections.addAll(response.asCollections()) + i++ + } + } catch (t: Throwable) { + Timber.i("Failed to retrieve books: $t") + } + } + + withContext(Dispatchers.IO) { + try { + val collectionsWithChildIds = networkCollections.map { + val collectionItems = plexMediaService.fetchBooksInCollection(it.id) + .plexMediaContainer + .asAudiobooks() + + val childIds = collectionItems.map { book -> book.id.toLong() } + it.copy(childIds = childIds) + } + collectionsDao.insertAll(collectionsWithChildIds) + } catch (t: Throwable) { + Timber.i("Failed to retrieve books: $t") + } + } + } + + suspend fun clear() { + collectionsDao.clear() + } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/LibrarySyncRepository.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/LibrarySyncRepository.kt new file mode 100644 index 0000000..865de51 --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/local/LibrarySyncRepository.kt @@ -0,0 +1,63 @@ +package io.github.mattpvaughn.chronicle.data.local + +import android.widget.Toast +import android.widget.Toast.LENGTH_LONG +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.github.mattpvaughn.chronicle.application.Injector +import io.github.mattpvaughn.chronicle.data.model.getProgress +import io.github.mattpvaughn.chronicle.data.sources.plex.model.getDuration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LibrarySyncRepository @Inject constructor( + private val bookRepository: BookRepository, + private val trackRepository: TrackRepository, + private val collectionsRepository: CollectionsRepository +) { + + private var repoJob = Job() + private val repoScope = CoroutineScope(repoJob + Dispatchers.IO) + + private var _isRefreshing = MutableLiveData() + val isRefreshing: LiveData + get() = _isRefreshing + + fun refreshLibrary() { + repoScope.launch { + try { + _isRefreshing.postValue(true) + bookRepository.refreshDataPaginated() + trackRepository.refreshDataPaginated() + } catch (e: Throwable) { + val msg = "Failed to refresh data: ${e.message}" + Toast.makeText(Injector.get().applicationContext(), msg, LENGTH_LONG).show() + } finally { + _isRefreshing.postValue(false) + } + + // TODO: Loading all data into memory :O + val audiobooks = bookRepository.getAllBooksAsync() + val tracks = trackRepository.getAllTracksAsync() + audiobooks.forEach { book -> + // TODO: O(n^2) so could be bad for big libs, grouping by tracks first would be O(n) + + // Not necessarily in the right order, but it doesn't matter for updateTrackData + val tracksInAudiobook = tracks.filter { it.parentKey == book.id } + bookRepository.updateTrackData( + bookId = book.id, + bookProgress = tracksInAudiobook.getProgress(), + bookDuration = tracksInAudiobook.getDuration(), + trackCount = tracksInAudiobook.size + ) + } + + collectionsRepository.refreshCollectionsPaginated() + } + } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/model/Audiobook.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/model/Audiobook.kt index 96ffa8a..5c2d70c 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/data/model/Audiobook.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/model/Audiobook.kt @@ -14,8 +14,8 @@ import io.github.mattpvaughn.chronicle.data.sources.SourceManager import io.github.mattpvaughn.chronicle.data.sources.plex.* import io.github.mattpvaughn.chronicle.data.sources.plex.model.PlexDirectory import io.github.mattpvaughn.chronicle.features.player.* -import kotlin.time.minutes -import kotlin.time.seconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds @TypeConverters(ChapterListConverter::class) @Entity @@ -178,7 +178,7 @@ fun Audiobook.toMediaItem(plexConfig: PlexConfig): MediaBrowserCompat.MediaItem } fun Audiobook.isCompleted(): Boolean { - return progress < 10.seconds.inMilliseconds || progress > (duration - 2.minutes.inMilliseconds) + return progress < 10.seconds.inWholeMilliseconds || progress > (duration - 2.minutes.inWholeMilliseconds) } fun Audiobook.uniqueId(): Int { diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/model/Collection.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/model/Collection.kt new file mode 100644 index 0000000..3be6cad --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/model/Collection.kt @@ -0,0 +1,81 @@ +package io.github.mattpvaughn.chronicle.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import com.squareup.moshi.Types +import io.github.mattpvaughn.chronicle.application.Injector +import io.github.mattpvaughn.chronicle.data.sources.MediaSource +import io.github.mattpvaughn.chronicle.data.sources.SourceManager +import io.github.mattpvaughn.chronicle.data.sources.plex.PlexMediaSource +import io.github.mattpvaughn.chronicle.data.sources.plex.model.PlexDirectory + +@TypeConverters(CollectionIdConverter::class) +@Entity +data class Collection constructor( + @PrimaryKey + val id: Int, + /** Unique long representing a [MediaSource] in [SourceManager] */ + val source: Long, + val title: String, + val childCount: Long = 0L, + val sortType: SortType = SortType.RELEASE_DATE, + val isCached: Boolean = false, + val thumb: String = "", + val childIds: List = emptyList() +) { + + companion object { + fun from(dir: PlexDirectory) = Collection( + id = dir.ratingKey.toInt(), + source = PlexMediaSource.MEDIA_SOURCE_ID_PLEX, + title = dir.title, + childCount = dir.childCount, + sortType = SortType.fromPlexCode(dir.collectionSort.toInt()), + thumb = dir.thumb + ) + + val PLEX_COLLECTION_SORT_TYPE_RELEASE_DATE = 0 + val PLEX_COLLECTION_SORT_TYPE_ALPHABETICAL = 1 + val PLEX_COLLECTION_SORT_TYPE_CUSTOM = 2 + } + + enum class SortType() { + RELEASE_DATE, + ALPHABETICAL, + CUSTOM; + + companion object { + fun fromPlexCode(plexCode: Int?): SortType { + return when (plexCode) { + PLEX_COLLECTION_SORT_TYPE_RELEASE_DATE -> RELEASE_DATE + PLEX_COLLECTION_SORT_TYPE_ALPHABETICAL -> ALPHABETICAL + PLEX_COLLECTION_SORT_TYPE_CUSTOM -> CUSTOM + else -> RELEASE_DATE + } + } + } + } +} + +class CollectionIdConverter { + + private val stringType = Types.newParameterizedType(List::class.java, String::class.java) + private val stringsAdapter = Injector.get().moshi().adapter>(stringType) + + @TypeConverter + fun fromList(value: List): String { + return stringsAdapter.toJson(value.map { it.toString() }) + } + + @TypeConverter + fun toList(value: String): List { + if (value.isEmpty()) { + return emptyList() + } + return stringsAdapter.fromJson(value) + ?.map { it.toLong() } + ?: emptyList() + } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/MediaSource.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/MediaSource.kt index 595b985..50f184f 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/MediaSource.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/MediaSource.kt @@ -1,7 +1,7 @@ package io.github.mattpvaughn.chronicle.data.sources import com.github.michaelbull.result.Result -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import com.google.android.exoplayer2.upstream.DefaultDataSource import io.github.mattpvaughn.chronicle.data.model.Audiobook import io.github.mattpvaughn.chronicle.data.model.MediaItemTrack @@ -14,7 +14,7 @@ interface MediaSource { * Expose a [DefaultDataSourceFactory] which can transform a [List] into a * [com.google.android.exoplayer2.source.ConcatenatingMediaSource] */ - val dataSourceFactory: DefaultDataSourceFactory + val dataSourceFactory: DefaultDataSource.Factory /** Fetch all audiobooks */ suspend fun fetchAudiobooks(): Result, Throwable> diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/local/LocalMediaSource.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/local/LocalMediaSource.kt index 2128034..e8e4150 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/local/LocalMediaSource.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/local/LocalMediaSource.kt @@ -1,7 +1,7 @@ package io.github.mattpvaughn.chronicle.data.sources.local import com.github.michaelbull.result.Result -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import com.google.android.exoplayer2.upstream.DefaultDataSource import io.github.mattpvaughn.chronicle.data.model.Audiobook import io.github.mattpvaughn.chronicle.data.model.MediaItemTrack import io.github.mattpvaughn.chronicle.data.sources.MediaSource @@ -17,7 +17,7 @@ class LocalMediaSource : MediaSource { // TODO: acquire the permissions needed somehow - override val dataSourceFactory: DefaultDataSourceFactory + override val dataSourceFactory: DefaultDataSource.Factory get() = TODO("Not yet implemented") override suspend fun fetchAudiobooks(): Result, Throwable> { diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/PlexMediaSource.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/PlexMediaSource.kt index 785ee47..a9f78bd 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/PlexMediaSource.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/PlexMediaSource.kt @@ -2,8 +2,8 @@ package io.github.mattpvaughn.chronicle.data.sources.plex import android.content.Context import com.github.michaelbull.result.Result -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource import com.tonyodev.fetch2.Request import io.github.mattpvaughn.chronicle.data.model.Audiobook import io.github.mattpvaughn.chronicle.data.model.MediaItemTrack @@ -18,7 +18,7 @@ class PlexMediaSource @Inject constructor( private val plexMediaService: PlexMediaService, private val plexLoginRepo: IPlexLoginRepo, private val appContext: Context, - defaultDataSourceFactory: DefaultHttpDataSourceFactory + defaultDataSourceFactory: DefaultHttpDataSource.Factory ) : HttpMediaSource { override val id: Long = MEDIA_SOURCE_ID_PLEX @@ -27,7 +27,7 @@ class PlexMediaSource @Inject constructor( const val MEDIA_SOURCE_ID_PLEX: Long = 0L } - override val dataSourceFactory: DefaultDataSourceFactory = DefaultDataSourceFactory(appContext, defaultDataSourceFactory) + override val dataSourceFactory: DefaultDataSource.Factory = DefaultDataSource.Factory(appContext, defaultDataSourceFactory) override val isDownloadable: Boolean = true diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/PlexService.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/PlexService.kt index 02964e6..611437e 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/PlexService.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/PlexService.kt @@ -133,4 +133,16 @@ interface PlexMediaService { @Query("X-Plex-Container-Start") containerStart: Int = 0, @Query("X-Plex-Container-Size") containerSize: Int = 100, ): PlexMediaContainerWrapper + + @GET("/library/sections/{libraryId}/collections?includeCollections=1") + suspend fun retrieveCollectionsPaginated( + @Path("libraryId") libraryId: String, + @Query("X-Plex-Container-Start") containerStart: Int = 0, + @Query("X-Plex-Container-Size") containerSize: Int = 100, + ): PlexMediaContainerWrapper + + @GET("/library/collections/{collectionId}/children") + suspend fun fetchBooksInCollection( + @Path("collectionId") collectionId: Int, + ): PlexMediaContainerWrapper } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/model/PlexDirectory.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/model/PlexDirectory.kt index 978cc68..edb6de0 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/model/PlexDirectory.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/model/PlexDirectory.kt @@ -2,6 +2,7 @@ package io.github.mattpvaughn.chronicle.data.sources.plex.model import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import io.github.mattpvaughn.chronicle.data.model.Collection.Companion.PLEX_COLLECTION_SORT_TYPE_RELEASE_DATE import io.github.mattpvaughn.chronicle.data.model.PlexLibrary /** @@ -38,9 +39,16 @@ data class PlexDirectory( val parentIndex: Int = 1, @Json(name = "Media") val media: List = emptyList(), - val viewOffset: Long = 0L + val viewOffset: Long = 0L, + @Json(name = "Collection") + val collections: List? = null, + val childCount: Long = 0L, + val collectionSort: String = PLEX_COLLECTION_SORT_TYPE_RELEASE_DATE.toString() ) +@JsonClass(generateAdapter = true) +data class CollectionWrapper(val tag: String? = null) + fun PlexDirectory.asLibrary(): PlexLibrary { return PlexLibrary( name = title, diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/model/PlexMediaContainer.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/model/PlexMediaContainer.kt index e8840c5..d6d72bf 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/model/PlexMediaContainer.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/data/sources/plex/model/PlexMediaContainer.kt @@ -3,6 +3,7 @@ package io.github.mattpvaughn.chronicle.data.sources.plex.model import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import io.github.mattpvaughn.chronicle.data.model.Audiobook +import io.github.mattpvaughn.chronicle.data.model.Collection import io.github.mattpvaughn.chronicle.data.model.MediaItemTrack @JsonClass(generateAdapter = true) @@ -32,3 +33,7 @@ fun PlexMediaContainer.asAudiobooks(): List { fun PlexMediaContainer.asTrackList(): List { return metadata.asMediaItemTracks() } + +fun PlexMediaContainer.asCollections(): List { + return metadata.map { Collection.from(it) } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/bookdetails/AudiobookDetailsFragment.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/bookdetails/AudiobookDetailsFragment.kt index 4c917be..1c63fb9 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/bookdetails/AudiobookDetailsFragment.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/bookdetails/AudiobookDetailsFragment.kt @@ -86,7 +86,7 @@ class AudiobookDetailsFragment : Fragment() { isCached = inputCached ) viewModel = - ViewModelProvider(this, viewModelFactory).get(AudiobookDetailsViewModel::class.java) + ViewModelProvider(this, viewModelFactory)[AudiobookDetailsViewModel::class.java] binding.viewModel = viewModel binding.lifecycleOwner = viewLifecycleOwner diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/bookdetails/AudiobookDetailsViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/bookdetails/AudiobookDetailsViewModel.kt index 4d0d4e6..5cdfbe9 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/bookdetails/AudiobookDetailsViewModel.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/bookdetails/AudiobookDetailsViewModel.kt @@ -67,7 +67,7 @@ class AudiobookDetailsViewModel( private val currentlyPlaying: CurrentlyPlaying ) : ViewModelProvider.Factory { lateinit var inputAudiobook: Audiobook - override fun create(modelClass: Class): T { + override fun create(modelClass: Class): T { check(this::inputAudiobook.isInitialized) { "Input audiobook not provided!" } if (modelClass.isAssignableFrom(AudiobookDetailsViewModel::class.java)) { return AudiobookDetailsViewModel( @@ -129,7 +129,7 @@ class AudiobookDetailsViewModel( } } - val cacheIconTint = Transformations.map(cacheStatus) { status -> + val cacheIconTint = cacheStatus.map { status -> return@map when (status) { CACHING -> R.color.icon // Doesn't matter, we show a spinner over it NOT_CACHED -> R.color.icon @@ -138,7 +138,7 @@ class AudiobookDetailsViewModel( } } - val cacheIconDrawable: LiveData = Transformations.map(cacheStatus) { status -> + val cacheIconDrawable: LiveData = cacheStatus.map { status -> return@map when (status) { CACHING -> R.drawable.ic_cloud_download_white // Doesn't matter, we show a spinner over it NOT_CACHED -> R.drawable.ic_cloud_download_white @@ -166,7 +166,7 @@ class AudiobookDetailsViewModel( return@DoubleLiveData isBookActive ?: false && currState?.isPlaying ?: false } - val progressString = Transformations.map(tracks) { tracks: List -> + val progressString = tracks.map { tracks: List -> if (tracks.isEmpty()) { return@map "0:00/0:00" } @@ -175,7 +175,7 @@ class AudiobookDetailsViewModel( return@map "$progressStr/$durationStr" } - val progressPercentageString = Transformations.map(tracks) { tracks: List -> + val progressPercentageString = tracks.map { tracks: List -> return@map "${tracks.getProgressPercentage()}%" } @@ -194,7 +194,7 @@ class AudiobookDetailsViewModel( val summaryLinesShown: LiveData get() = _summaryLinesShown - val isAudioLoading = Transformations.map(mediaServiceConnection.playbackState) { state -> + val isAudioLoading = mediaServiceConnection.playbackState.map { state -> if (state.state == PlaybackStateCompat.STATE_ERROR) { Timber.i("Playback state: ${state.stateName}, (${state.errorMessage})") } else { @@ -203,15 +203,15 @@ class AudiobookDetailsViewModel( state.state == STATE_BUFFERING || state.state == STATE_CONNECTING } - val showSummary = Transformations.map(audiobook) { book -> + val showSummary = audiobook.map { book -> book?.summary?.isNotEmpty() ?: false } - val isExpanded = Transformations.map(summaryLinesShown) { lines -> + val isExpanded = summaryLinesShown.map { lines -> return@map lines == lineCountSummaryMaximized } - val serverConnection = Transformations.map(plexConfig.connectionState) { it } + val serverConnection = plexConfig.connectionState.map { it } fun onToggleSummaryView() { _summaryLinesShown.value = diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionDetailsFragment.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionDetailsFragment.kt new file mode 100644 index 0000000..a2c311b --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionDetailsFragment.kt @@ -0,0 +1,142 @@ +package io.github.mattpvaughn.chronicle.features.collections + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.github.mattpvaughn.chronicle.application.MainActivity +import io.github.mattpvaughn.chronicle.data.local.IBookRepository +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo +import io.github.mattpvaughn.chronicle.data.model.Audiobook +import io.github.mattpvaughn.chronicle.data.sources.plex.PlexConfig +import io.github.mattpvaughn.chronicle.databinding.FragmentCollectionDetailsBinding +import io.github.mattpvaughn.chronicle.features.library.AudiobookAdapter +import io.github.mattpvaughn.chronicle.features.library.LibraryFragment +import io.github.mattpvaughn.chronicle.navigation.Navigator +import kotlinx.coroutines.ExperimentalCoroutinesApi +import timber.log.Timber +import javax.inject.Inject + +@ExperimentalCoroutinesApi +class CollectionDetailsFragment : Fragment() { + + companion object { + fun newInstance(collectionId: Int): CollectionDetailsFragment { + val newFrag = CollectionDetailsFragment() + val args = Bundle() + args.putInt(ARG_COLLECTION_ID, collectionId) + newFrag.arguments = args + return newFrag + } + + const val TAG = "collection details tag" + const val ARG_COLLECTION_ID = "collection_id" + } + + @Inject + lateinit var prefsRepo: PrefsRepo + + @Inject + lateinit var navigator: Navigator + + @Inject + lateinit var bookRepository: IBookRepository + + @Inject + lateinit var plexConfig: PlexConfig + + lateinit var viewModel: CollectionDetailsViewModel + + @Inject + lateinit var viewModelFactory: CollectionDetailsViewModel.Factory + + var adapter: AudiobookAdapter? = null + + override fun onAttach(context: Context) { + (requireActivity() as MainActivity).activityComponent!!.inject(this) + Timber.i("CollectionDetailsFragment onAttach()") + super.onAttach(context) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Timber.i("AudiobookDetailsFragment onCreateView()") + + val binding = FragmentCollectionDetailsBinding.inflate(inflater, container, false) + + val inputId = requireArguments().getInt(ARG_COLLECTION_ID) + + viewModelFactory.collectionId = inputId + viewModel = ViewModelProvider(this, viewModelFactory) + .get(CollectionDetailsViewModel::class.java) + + adapter = AudiobookAdapter( + prefsRepo.libraryBookViewStyle, + true, + prefsRepo.bookCoverStyle == PrefsRepo.BOOK_COVER_STYLE_SQUARE, + object : LibraryFragment.AudiobookClick { + override fun onClick(audiobook: Audiobook) { + openAudiobookDetails(audiobook) + } + } + ).apply { + stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + viewModel.viewStyle.observe(viewLifecycleOwner) { style -> + Timber.i("View style is: $style") + val isGrid = when (style) { + PrefsRepo.VIEW_STYLE_COVER_GRID -> true + PrefsRepo.VIEW_STYLE_DETAILS_LIST, PrefsRepo.VIEW_STYLE_TEXT_LIST -> false + else -> throw IllegalStateException("Unknown view style") + } + binding.collectionsGrid.layoutManager = if (isGrid) { + GridLayoutManager(requireContext(), 3) + } else { + LinearLayoutManager(requireContext()) + } + adapter!!.viewStyle = style + } + + binding.collectionsGrid.adapter = adapter + + viewModel.booksInCollection.observe(viewLifecycleOwner) { + adapter!!.submitList(it) + } + + (activity as AppCompatActivity).setSupportActionBar(binding.toolbar) + + binding.toolbar.setNavigationOnClickListener { + requireActivity().onBackPressed() + } + + viewModel.title.observe(viewLifecycleOwner) { + binding.toolbar.title = it?.title ?: "" + } + + binding.toolbar.setNavigationOnClickListener { + requireActivity().onBackPressed() + } + + return binding.root + } + + override fun onDestroyView() { + adapter = null + super.onDestroyView() + } + + private fun openAudiobookDetails(audiobook: Audiobook) { + navigator.showDetails(audiobook.id, audiobook.title, audiobook.isCached) + } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionDetailsViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionDetailsViewModel.kt new file mode 100644 index 0000000..72dee22 --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionDetailsViewModel.kt @@ -0,0 +1,68 @@ +package io.github.mattpvaughn.chronicle.features.collections + +import android.content.SharedPreferences +import androidx.lifecycle.* +import io.github.mattpvaughn.chronicle.data.local.* +import io.github.mattpvaughn.chronicle.data.model.Audiobook +import io.github.mattpvaughn.chronicle.util.StringPreferenceLiveData +import kotlinx.coroutines.launch +import javax.inject.Inject + +class CollectionDetailsViewModel( + private val collectionId: Int, + private val bookRepo: BookRepository, + private val collectionRepo: CollectionsRepository, + prefsRepo: PrefsRepo, + sharedPreferences: SharedPreferences +) : ViewModel() { + + private suspend fun getBooksInCollection(): List { + val childIds = collectionRepo.getChildIds(collectionId) + return childIds.mapNotNull { + bookRepo.getAudiobookAsync(it.toInt()) + } + } + + private val _booksInCollection = MutableLiveData>(emptyList()) + val booksInCollection: LiveData> + get() = _booksInCollection + + val title = collectionRepo.getCollection(collectionId) + + init { + viewModelScope.launch { + _booksInCollection.value = getBooksInCollection() + } + } + + val viewStyle = StringPreferenceLiveData( + PrefsRepo.KEY_LIBRARY_VIEW_STYLE, + prefsRepo.libraryBookViewStyle, + sharedPreferences + ) + + @Suppress("UNCHECKED_CAST") + class Factory @Inject constructor( + private val bookRepo: BookRepository, + private val prefsRepo: PrefsRepo, + private val sharedPreferences: SharedPreferences, + private val collectionRepo: CollectionsRepository, + ) : ViewModelProvider.Factory { + + var collectionId: Int? = null + + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(CollectionDetailsViewModel::class.java)) { + return CollectionDetailsViewModel( + collectionId!!, + bookRepo, + collectionRepo, + prefsRepo, + sharedPreferences + ) as T + } else { + throw IllegalArgumentException("Incorrect class type provided") + } + } + } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsAdapter.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsAdapter.kt new file mode 100644 index 0000000..49e6119 --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsAdapter.kt @@ -0,0 +1,169 @@ +package io.github.mattpvaughn.chronicle.features.collections + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.VIEW_STYLE_COVER_GRID +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.VIEW_STYLE_DETAILS_LIST +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.VIEW_STYLE_TEXT_LIST +import io.github.mattpvaughn.chronicle.data.model.Collection +import io.github.mattpvaughn.chronicle.databinding.* + +class CollectionsAdapter( + initialViewStyle: String, + private val isVertical: Boolean, + private val isSquare: Boolean, + private val collectionClick: CollectionsFragment.CollectionClick +) : ListAdapter(CollectionsDiffCallback()) { + + private val COVER_GRID = 1 + private val TEXT_ONLY = 2 + private val DETAILS = 3 + var viewStyle: String = initialViewStyle + set(value) { + viewStyleInt = when (value) { + VIEW_STYLE_COVER_GRID -> COVER_GRID + VIEW_STYLE_TEXT_LIST -> TEXT_ONLY + VIEW_STYLE_DETAILS_LIST -> DETAILS + else -> throw IllegalStateException("Unknown view style") + } + notifyDataSetChanged() + field = value + } + private var viewStyleInt: Int = when (initialViewStyle) { + VIEW_STYLE_COVER_GRID -> COVER_GRID + VIEW_STYLE_TEXT_LIST -> TEXT_ONLY + VIEW_STYLE_DETAILS_LIST -> DETAILS + else -> throw IllegalStateException("Unknown view style") + } + + private var serverConnected: Boolean = false + + override fun getItemId(position: Int): Long { + return getItem(position).id.toLong() + } + + override fun getItemViewType(position: Int): Int { + return viewStyleInt + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + COVER_GRID -> ViewHolder.from(parent, isVertical, isSquare) + TEXT_ONLY -> TextOnlyViewHolder.from(parent) + DETAILS -> DetailsStyleViewHolder.from(parent, isSquare) + else -> throw IllegalStateException("Unknown view type") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ViewHolder -> { + holder.bind(getItem(position), collectionClick, serverConnected) + } + is TextOnlyViewHolder -> { + holder.bind(getItem(position), collectionClick) + } + is DetailsStyleViewHolder -> { + holder.bind(getItem(position), collectionClick, serverConnected, isSquare) + } + else -> throw IllegalStateException("Unknown view type") + } + } + + fun setServerConnected(serverConnected: Boolean) { + this.serverConnected = serverConnected + notifyDataSetChanged() + } + + class ViewHolder( + val binding: GridItemCollectionBinding, + private val isVertical: Boolean, + private val isSquare: Boolean + ) : RecyclerView.ViewHolder(binding.root) { + fun bind( + collection: Collection, + collectionClick: CollectionsFragment.CollectionClick, + serverConnected: Boolean + ) { + binding.isSquare = isSquare + binding.collection = collection + binding.isVertical = isVertical + binding.collectionClick = collectionClick + binding.serverConnected = serverConnected + binding.executePendingBindings() + } + + companion object { + fun from( + viewGroup: ViewGroup, + isVertical: Boolean, + isSquare: Boolean + ): ViewHolder { + val inflater = LayoutInflater.from(viewGroup.context) + val binding = GridItemCollectionBinding.inflate(inflater, viewGroup, false) + return ViewHolder(binding, isVertical, isSquare) + } + } + } + + class TextOnlyViewHolder(val binding: ListItemCollectionTextOnlyBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(collection: Collection, collectionClick: CollectionsFragment.CollectionClick) { + binding.collection = collection + binding.collectionClick = collectionClick + binding.executePendingBindings() + } + + companion object { + fun from(viewGroup: ViewGroup): TextOnlyViewHolder { + val inflater = LayoutInflater.from(viewGroup.context) + val binding = + ListItemCollectionTextOnlyBinding.inflate(inflater, viewGroup, false) + return TextOnlyViewHolder(binding) + } + } + } +} + +class DetailsStyleViewHolder( + val binding: ListItemCollectionWithDetailsBinding, + val isSquare: Boolean +) : RecyclerView.ViewHolder(binding.root) { + fun bind( + collection: Collection, + collectionClick: CollectionsFragment.CollectionClick, + serverConnected: Boolean, + isSquare: Boolean + ) { + binding.isSquare = isSquare + binding.collection = collection + binding.collectionClick = collectionClick + binding.serverConnected = serverConnected + binding.executePendingBindings() + } + + companion object { + fun from(viewGroup: ViewGroup, isSquare: Boolean): DetailsStyleViewHolder { + val inflater = LayoutInflater.from(viewGroup.context) + val binding = + ListItemCollectionWithDetailsBinding.inflate(inflater, viewGroup, false) + return DetailsStyleViewHolder(binding, isSquare) + } + } +} + +class CollectionsDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Collection, newItem: Collection): Boolean { + return oldItem.id == newItem.id + } + + /** Changes which require a redraw of the view */ + override fun areContentsTheSame(oldItem: Collection, newItem: Collection): Boolean { + return oldItem.childCount == newItem.childCount && + oldItem.thumb == newItem.thumb && + oldItem.title == newItem.title + } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsFragment.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsFragment.kt new file mode 100644 index 0000000..315196f --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsFragment.kt @@ -0,0 +1,244 @@ +package io.github.mattpvaughn.chronicle.features.collections + +import android.content.Context +import android.os.Bundle +import android.view.* +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy +import io.github.mattpvaughn.chronicle.R +import io.github.mattpvaughn.chronicle.application.MainActivity +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.BOOK_COVER_STYLE_SQUARE +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.VIEW_STYLE_COVER_GRID +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.VIEW_STYLE_DETAILS_LIST +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.VIEW_STYLE_TEXT_LIST +import io.github.mattpvaughn.chronicle.data.model.Audiobook +import io.github.mattpvaughn.chronicle.data.model.Collection +import io.github.mattpvaughn.chronicle.data.sources.plex.PlexConfig +import io.github.mattpvaughn.chronicle.databinding.FragmentCollectionsBinding +import io.github.mattpvaughn.chronicle.features.library.AudiobookSearchAdapter +import io.github.mattpvaughn.chronicle.features.library.LibraryFragment +import io.github.mattpvaughn.chronicle.navigation.Navigator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +/** TODO: refactor search to reuse code from Library + Home fragments */ +class CollectionsFragment : Fragment() { + + companion object { + fun newInstance() = CollectionsFragment() + } + + @Inject + lateinit var viewModelFactory: CollectionsViewModel.Factory + + private val viewModel: CollectionsViewModel by lazy { + ViewModelProvider(this, viewModelFactory).get(CollectionsViewModel::class.java) + } + + @Inject + lateinit var prefsRepo: PrefsRepo + + @Inject + lateinit var navigator: Navigator + + @Inject + lateinit var plexConfig: PlexConfig + + var adapter: CollectionsAdapter? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Timber.i("Lib frag view create") + val binding = FragmentCollectionsBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel + binding.plexConfig = plexConfig + + adapter = CollectionsAdapter( + prefsRepo.libraryBookViewStyle, + true, + prefsRepo.bookCoverStyle == BOOK_COVER_STYLE_SQUARE, + object : CollectionClick { + override fun onClick(collection: Collection) { + openCollectionDetails(collection) + } + } + ).apply { + stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + binding.collectionsGrid.adapter = adapter + + viewModel.collections.observe(viewLifecycleOwner) { collections -> + // Adapter is always non-null between view creation and view destruction + if (adapter == null) { + return@observe + } + + // If there are no previous books, submit normally + if (adapter!!.currentList.isNullOrEmpty()) { + Timber.i("Updating book list: no previous books") + adapter!!.submitList(collections) + return@observe + } + + // Sometimes [books] will be the same as [adapter.currentList] so don't do any + // submission/diffing if that's the case + // + // Check if the new list differs from the current. We really should be using a normal + // RecyclerView.Adapter and not a ListAdapter for this, as ListAdapter only provides + // access to an immutable copy of a list, not the list itself. + // + // This operation is worst case O(n), which is bad for users with huge libraries + lifecycleScope.launch { + val isNewList = withContext(Dispatchers.IO) { + val currentList = adapter?.currentList ?: return@withContext true + if (collections.size != currentList.size) { + Timber.i("Updating: different size!") + return@withContext true + } + // compare lists by id, faster than doing a full .equals() comparison + for (index in collections.indices) { + if (collections[index].id != currentList[index].id) { + Timber.i("Updating: different ids!") + return@withContext true + } + } + return@withContext false + } + if (isNewList) { + // submit an empty list to force a scroll-to-top, then when it is done, submit + // the real list + Timber.i("Updating book list: scroll to top") + adapter!!.submitList(null) { adapter?.submitList(collections) } + } + } + } + + plexConfig.isConnected.observe(viewLifecycleOwner) { isConnected -> + adapter?.setServerConnected(isConnected) + } + + viewModel.viewStyle.observe(viewLifecycleOwner) { style -> + Timber.i("View style is: $style") + val isGrid = when (style) { + VIEW_STYLE_COVER_GRID -> true + VIEW_STYLE_DETAILS_LIST, VIEW_STYLE_TEXT_LIST -> false + else -> throw IllegalStateException("Unknown view style") + } + binding.collectionsGrid.layoutManager = if (isGrid) { + GridLayoutManager(requireContext(), 3) + } else { + LinearLayoutManager(requireContext()) + } + adapter!!.viewStyle = style + } + binding.searchResultsList.adapter = AudiobookSearchAdapter(object : LibraryFragment.AudiobookClick { + override fun onClick(audiobook: Audiobook) { + openAudiobookDetails(audiobook) + } + }) + + binding.swipeToRefresh.setOnRefreshListener { + viewModel.refreshData() + } + + viewModel.isRefreshing.observe(viewLifecycleOwner) { + binding.swipeToRefresh.isRefreshing = it + } + + viewModel.messageForUser.observe(viewLifecycleOwner) { + if (!it.hasBeenHandled) { + Toast.makeText(context, it.getContentIfNotHandled(), LENGTH_SHORT).show() + } + } + + (activity as AppCompatActivity).setSupportActionBar(binding.toolbar) + + return binding.root + } + + private fun openCollectionDetails(collection: Collection) { + navigator.showCollectionDetails(collection.id) + } + + private fun openAudiobookDetails(audiobook: Audiobook) { + navigator.showDetails(audiobook.id, audiobook.title, audiobook.isCached) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.collections_menu, menu) + val searchView = menu.findItem(R.id.search).actionView as SearchView + val searchItem = menu.findItem(R.id.search) as MenuItem + + searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + viewModel.setSearchActive(true) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + viewModel.setSearchActive(false) + return true + } + }) + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + // Do nothing + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (newText != null) { + viewModel.search(newText) + } + return true + } + }) + } + + override fun onAttach(context: Context) { + (activity as MainActivity).activityComponent!!.inject(this) + super.onAttach(context) + Timber.i("Reattached!") + } + + override fun onDestroyView() { + adapter = null + super.onDestroyView() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.search -> { + } // handled by listeners in onCreateView + else -> throw NoWhenBranchMatchedException("Unknown menu item selected!") + } + return super.onOptionsItemSelected(item) + } + + interface CollectionClick { + fun onClick(collection: Collection) + } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsViewModel.kt new file mode 100644 index 0000000..c156b19 --- /dev/null +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/collections/CollectionsViewModel.kt @@ -0,0 +1,193 @@ +package io.github.mattpvaughn.chronicle.features.collections + +import android.content.SharedPreferences +import androidx.lifecycle.* +import io.github.mattpvaughn.chronicle.application.Injector +import io.github.mattpvaughn.chronicle.data.local.BookRepository +import io.github.mattpvaughn.chronicle.data.local.CollectionsRepository +import io.github.mattpvaughn.chronicle.data.local.LibrarySyncRepository +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.KEY_BOOK_SORT_BY +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.KEY_HIDE_PLAYED_AUDIOBOOKS +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.KEY_IS_LIBRARY_SORT_DESCENDING +import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.KEY_LIBRARY_VIEW_STYLE +import io.github.mattpvaughn.chronicle.data.model.Audiobook +import io.github.mattpvaughn.chronicle.data.model.Audiobook.Companion.SORT_KEY_TITLE +import io.github.mattpvaughn.chronicle.data.model.Collection +import io.github.mattpvaughn.chronicle.util.* +import io.github.mattpvaughn.chronicle.views.BottomSheetChooser +import io.github.mattpvaughn.chronicle.views.BottomSheetChooser.BottomChooserState.Companion.EMPTY_BOTTOM_CHOOSER +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class CollectionsViewModel( + private val prefsRepo: PrefsRepo, + private val librarySyncRepository: LibrarySyncRepository, + collectionsRepository: CollectionsRepository, + sharedPreferences: SharedPreferences, + private val bookRepository: BookRepository +) : ViewModel() { + + @Suppress("UNCHECKED_CAST") + class Factory @Inject constructor( + private val prefsRepo: PrefsRepo, + private val collectionsRepository: CollectionsRepository, + private val librarySyncRepository: LibrarySyncRepository, + private val sharedPreferences: SharedPreferences, + private val bookRepository: BookRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(CollectionsViewModel::class.java)) { + return CollectionsViewModel( + prefsRepo, + librarySyncRepository, + collectionsRepository, + sharedPreferences, + bookRepository + ) as T + } else { + throw IllegalArgumentException("Cannot instantiate $modelClass from LibraryViewModel.Factory") + } + } + } + + val isRefreshing = librarySyncRepository.isRefreshing + + private var _isSearchActive = MutableLiveData() + val isSearchActive: LiveData + get() = _isSearchActive + + val viewStyle = StringPreferenceLiveData( + KEY_LIBRARY_VIEW_STYLE, + prefsRepo.libraryBookViewStyle, + sharedPreferences + ) + + val isSortDescending = BooleanPreferenceLiveData( + KEY_IS_LIBRARY_SORT_DESCENDING, + true, + sharedPreferences + ) + + val arePlayedAudiobooksHidden = BooleanPreferenceLiveData( + KEY_HIDE_PLAYED_AUDIOBOOKS, + false, + sharedPreferences + ) + + private val sortKey = StringPreferenceLiveData( + KEY_BOOK_SORT_BY, + SORT_KEY_TITLE, + sharedPreferences + ) + + private var prevCollections = emptyList() + + private val allCollections = collectionsRepository.getAllCollections() + val collections = QuadLiveDataAsync( + viewModelScope, + allCollections, + isSortDescending, + sortKey, + arePlayedAudiobooksHidden, + ) { _collections, _isDescending, _sortKey, _hidePlayed -> + if (_collections.isNullOrEmpty()) { + return@QuadLiveDataAsync emptyList() + } + + // TODO: Currently only support sort by title! + val key = SORT_KEY_TITLE + + // Use defaults if provided null values + val desc = _isDescending ?: true + val hidePlayed = _hidePlayed ?: false + + val results = _collections.sortedWith( + Comparator { coll1, coll2 -> + val descMultiplier = if (desc) 1 else -1 + return@Comparator descMultiplier * when (key) { + SORT_KEY_TITLE -> coll1.title.compareTo(coll2.title) + else -> throw NoWhenBranchMatchedException("Unknown sort key: $key") + } + } + ) + + // If nothing has changed, return prevBooks + if (prevCollections.map { it.id } == results.map { it.id }) { + return@QuadLiveDataAsync prevCollections + } + + prevCollections = results + + return@QuadLiveDataAsync results + } + + private var _messageForUser = MutableLiveData>() + val messageForUser: LiveData> + get() = _messageForUser + + private var _searchResults = MutableLiveData>() + val searchResults: LiveData> + get() = _searchResults + + private var _isQueryEmpty = MutableLiveData(false) + val isQueryEmpty: LiveData + get() = _isQueryEmpty + + private var _bottomChooserState = MutableLiveData(EMPTY_BOTTOM_CHOOSER) + val bottomChooserState: LiveData + get() = _bottomChooserState + + fun setSearchActive(isSearchActive: Boolean) { + _isSearchActive.postValue(isSearchActive) + } + + /** Searches for books which match the provided text */ + fun search(query: String) { + _isQueryEmpty.postValue(query.isEmpty()) + if (query.isEmpty()) { + _searchResults.postValue(emptyList()) + } else { + bookRepository.search(query).observeOnce( + Observer { + _searchResults.postValue(it) + } + ) + } + } + + private val serverConnectionObserver = Observer { isConnectedToServer -> + if (isConnectedToServer) { + viewModelScope.launch(Injector.get().unhandledExceptionHandler()) { + val millisSinceLastRefresh = + System.currentTimeMillis() - prefsRepo.lastRefreshTimeStamp + val minutesSinceLastRefresh = millisSinceLastRefresh / 1000 / 60 + val bookCount = bookRepository.getBookCount() + val shouldRefresh = + minutesSinceLastRefresh > prefsRepo.refreshRateMinutes || bookCount == 0 + Timber.i( + """$minutesSinceLastRefresh minutes since last libraryrefresh, + |${prefsRepo.refreshRateMinutes} needed""".trimMargin() + ) + if (shouldRefresh) { + refreshData() + } + } + } + } + + fun disableOfflineMode() { + prefsRepo.offlineMode = false + } + + fun refreshData() { + librarySyncRepository.refreshLibrary() + } + + /** Toggles whether to show or hide played audiobooks in the library */ + fun toggleHidePlayedAudiobooks() { + Timber.i("toggleHidePlayedAudiobooks") + prefsRepo.hidePlayedAudiobooks = !prefsRepo.hidePlayedAudiobooks + } +} diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/currentlyplaying/CurrentlyPlayingViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/currentlyplaying/CurrentlyPlayingViewModel.kt index dd53327..4966551 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/currentlyplaying/CurrentlyPlayingViewModel.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/currentlyplaying/CurrentlyPlayingViewModel.kt @@ -68,7 +68,7 @@ class CurrentlyPlayingViewModel( private val currentlyPlaying: CurrentlyPlaying, private val sharedPrefs: SharedPreferences, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { + override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(CurrentlyPlayingViewModel::class.java)) { return CurrentlyPlayingViewModel( bookRepository, @@ -92,7 +92,7 @@ class CurrentlyPlayingViewModel( private var audiobookId = MutableLiveData(EMPTY_AUDIOBOOK.id) - val audiobook: LiveData = Transformations.switchMap(audiobookId) { id -> + val audiobook: LiveData = audiobookId.switchMap { id -> if (id == EMPTY_AUDIOBOOK.id) { emptyAudiobook } else { @@ -104,7 +104,7 @@ class CurrentlyPlayingViewModel( private val emptyTrackList = MutableLiveData>(emptyList()) // TODO: expose combined track/chapter bits in ViewModel as "windowSomething" instead of in xml - val tracks: LiveData> = Transformations.switchMap(audiobookId) { id -> + val tracks: LiveData> = audiobookId.switchMap { id -> if (id == EMPTY_AUDIOBOOK.id) { emptyTrackList } else { @@ -138,7 +138,7 @@ class CurrentlyPlayingViewModel( return@map it.coerceIn(PLAYBACK_SPEED_MIN, PLAYBACK_SPEED_MAX) } - val playbackSpeedString = Transformations.map(speed) { speed -> + val playbackSpeedString = speed.map { speed -> return@map String.format("%.2f", speed) + "x" } @@ -147,7 +147,7 @@ class CurrentlyPlayingViewModel( get() = _showModalBottomSheetSpeedChooser val activeTrackId: LiveData = - Transformations.map(mediaServiceConnection.nowPlaying) { metadata -> + mediaServiceConnection.nowPlaying.map { metadata -> metadata.takeIf { !it.id.isNullOrEmpty() }?.id?.toInt() ?: TRACK_NOT_FOUND } @@ -160,7 +160,7 @@ class CurrentlyPlayingViewModel( track.progress - chapter.startTimeOffset }.asLiveData(viewModelScope.coroutineContext) - val chapterProgressString = Transformations.map(chapterProgress) { progress -> + val chapterProgressString = chapterProgress.map { progress -> return@map DateUtils.formatElapsedTime( StringBuilder(), progress / 1000 @@ -176,11 +176,11 @@ class CurrentlyPlayingViewModel( .map { it.progress } .asLiveData(viewModelScope.coroutineContext) - val chapterDuration = Transformations.map(currentChapter) { + val chapterDuration = currentChapter.map { return@map it.endTimeOffset - it.startTimeOffset } - val chapterDurationString = Transformations.map(chapterDuration) { duration -> + val chapterDurationString = chapterDuration.map { duration -> return@map DateUtils.formatElapsedTime( StringBuilder(), duration / 1000 @@ -195,27 +195,27 @@ class CurrentlyPlayingViewModel( private var sleepTimerTimeRemaining = MutableLiveData(0L) - val sleepTimerTimeRemainingString = Transformations.map(sleepTimerTimeRemaining) { + val sleepTimerTimeRemainingString = sleepTimerTimeRemaining.map { return@map DateUtils.formatElapsedTime(StringBuilder(), it / 1000) } val isPlaying: LiveData = - Transformations.map(mediaServiceConnection.playbackState) { state -> + mediaServiceConnection.playbackState.map { state -> return@map state.isPlaying } - val trackProgress = Transformations.map(currentTrack) { track -> + val trackProgress = currentTrack.map { track -> return@map DateUtils.formatElapsedTime( StringBuilder(), track.progress / 1000 ) } - val trackDuration = Transformations.map(currentTrack) { track -> + val trackDuration = currentTrack.map { track -> return@map DateUtils.formatElapsedTime(StringBuilder(), track.duration / 1000) } - val progressString = Transformations.map(tracks) { tracks: List -> + val progressString = tracks.map { tracks: List -> if (tracks.isEmpty()) { return@map "0:00/0:00" } @@ -224,7 +224,7 @@ class CurrentlyPlayingViewModel( return@map "$progressStr/$durationStr" } - val progressPercentageString = Transformations.map(tracks) { tracks: List -> + val progressPercentageString = tracks.map { tracks: List -> return@map "${tracks.getProgressPercentage()}%" } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/download/DownloadNotificationWorker.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/download/DownloadNotificationWorker.kt index a5fb195..69534cd 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/download/DownloadNotificationWorker.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/download/DownloadNotificationWorker.kt @@ -48,7 +48,7 @@ class DownloadNotificationWorker( applicationContext, ACTION_CANCEL_ALL_DOWNLOADS_ID, cancelAllIntent, - 0 + PendingIntent.FLAG_IMMUTABLE ) private val actionCancelAll = NotificationCompat.Action.Builder( R.drawable.fetch_notification_cancel, @@ -411,7 +411,7 @@ class DownloadNotificationWorker( Intent(ACTION_CANCEL_BOOK_DOWNLOAD).apply { putExtra(KEY_BOOK_ID, bookId) }, - PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val openBookPendingIntent = makeOpenBookPendingIntent(bookId) @@ -441,7 +441,7 @@ class DownloadNotificationWorker( applicationContext, REQUEST_CODE_PREFIX_OPEN_ACTIVITY_TO_AUDIOBOOK_WITH_ID + bookId, intent, - 0 + PendingIntent.FLAG_IMMUTABLE ) } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/home/HomeFragment.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/home/HomeFragment.kt index cd4c778..2be47ba 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/home/HomeFragment.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/home/HomeFragment.kt @@ -113,12 +113,12 @@ class HomeFragment : Fragment() { val searchItem = menu.findItem(R.id.search) as MenuItem searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { viewModel.setSearchActive(true) return true } - override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { viewModel.setSearchActive(false) return true } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/home/HomeViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/home/HomeViewModel.kt index 6476d98..773a570 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/home/HomeViewModel.kt @@ -5,17 +5,14 @@ import androidx.lifecycle.* import io.github.mattpvaughn.chronicle.application.Injector import io.github.mattpvaughn.chronicle.application.MainActivityViewModel import io.github.mattpvaughn.chronicle.data.local.IBookRepository -import io.github.mattpvaughn.chronicle.data.local.ITrackRepository +import io.github.mattpvaughn.chronicle.data.local.LibrarySyncRepository import io.github.mattpvaughn.chronicle.data.local.PrefsRepo import io.github.mattpvaughn.chronicle.data.model.Audiobook -import io.github.mattpvaughn.chronicle.data.model.getProgress import io.github.mattpvaughn.chronicle.data.sources.plex.PlexConfig -import io.github.mattpvaughn.chronicle.data.sources.plex.model.getDuration import io.github.mattpvaughn.chronicle.features.library.LibraryViewModel import io.github.mattpvaughn.chronicle.util.DoubleLiveData import io.github.mattpvaughn.chronicle.util.Event import io.github.mattpvaughn.chronicle.util.observeOnce -import io.github.mattpvaughn.chronicle.util.postEvent import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -23,7 +20,7 @@ import javax.inject.Inject class HomeViewModel( private val plexConfig: PlexConfig, private val bookRepository: IBookRepository, - private val trackRepository: ITrackRepository, + private val librarySyncRepository: LibrarySyncRepository, private val prefsRepo: PrefsRepo ) : ViewModel() { @@ -31,15 +28,15 @@ class HomeViewModel( class Factory @Inject constructor( private val plexConfig: PlexConfig, private val bookRepository: IBookRepository, - private val trackRepository: ITrackRepository, + private val librarySyncRepository: LibrarySyncRepository, private val prefsRepo: PrefsRepo, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { + override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(HomeViewModel::class.java)) { return HomeViewModel( plexConfig, bookRepository, - trackRepository, + librarySyncRepository, prefsRepo ) as T } else { @@ -62,9 +59,7 @@ class HomeViewModel( } ?: emptyList() } - private var _isRefreshing = MutableLiveData() - val isRefreshing: LiveData - get() = _isRefreshing + val isRefreshing = librarySyncRepository.isRefreshing private var _messageForUser = MutableLiveData>() val messageForUser: LiveData> @@ -163,32 +158,6 @@ class HomeViewModel( * [LibraryViewModel] */ fun refreshData() { - viewModelScope.launch(Injector.get().unhandledExceptionHandler()) { - try { - _isRefreshing.postValue(true) - bookRepository.refreshDataPaginated() - trackRepository.refreshDataPaginated() - } catch (e: Throwable) { - _messageForUser.postEvent("Failed to refresh data: ${e.message}") - } finally { - _isRefreshing.postValue(false) - } - - // Update audiobooks which depend on track data - val audiobooks = bookRepository.getAllBooksAsync() - val tracks = trackRepository.getAllTracksAsync() - audiobooks.forEach { book -> - // TODO: O(n^2) so could be bad for big libs, grouping by tracks first would be O(n) - - // Not necessarily in the right order, but it doesn't matter for updateTrackData - val tracksInAudiobook = tracks.filter { it.parentKey == book.id } - bookRepository.updateTrackData( - bookId = book.id, - bookProgress = tracksInAudiobook.getProgress(), - bookDuration = tracksInAudiobook.getDuration(), - trackCount = tracksInAudiobook.size - ) - } - } + librarySyncRepository.refreshLibrary() } } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/library/LibraryFragment.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/library/LibraryFragment.kt index 2be0b85..a9677c4 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/library/LibraryFragment.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/library/LibraryFragment.kt @@ -229,14 +229,14 @@ class LibraryFragment : Fragment() { val cacheItem = menu.findItem(R.id.download_all) as MenuItem searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { filterItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) cacheItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) viewModel.setSearchActive(true) return true } - override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { filterItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) cacheItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) viewModel.setSearchActive(false) diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/library/LibraryViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/library/LibraryViewModel.kt index 6e93149..e70af05 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/library/LibraryViewModel.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/library/LibraryViewModel.kt @@ -7,6 +7,7 @@ import io.github.mattpvaughn.chronicle.R import io.github.mattpvaughn.chronicle.application.Injector import io.github.mattpvaughn.chronicle.data.local.IBookRepository import io.github.mattpvaughn.chronicle.data.local.ITrackRepository +import io.github.mattpvaughn.chronicle.data.local.LibrarySyncRepository import io.github.mattpvaughn.chronicle.data.local.PrefsRepo import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.KEY_BOOK_SORT_BY import io.github.mattpvaughn.chronicle.data.local.PrefsRepo.Companion.KEY_HIDE_PLAYED_AUDIOBOOKS @@ -22,11 +23,9 @@ import io.github.mattpvaughn.chronicle.data.model.Audiobook.Companion.SORT_KEY_P import io.github.mattpvaughn.chronicle.data.model.Audiobook.Companion.SORT_KEY_TITLE import io.github.mattpvaughn.chronicle.data.model.Audiobook.Companion.SORT_KEY_YEAR import io.github.mattpvaughn.chronicle.data.model.MediaItemTrack -import io.github.mattpvaughn.chronicle.data.model.getProgress import io.github.mattpvaughn.chronicle.data.sources.plex.ICachedFileManager import io.github.mattpvaughn.chronicle.data.sources.plex.ICachedFileManager.CacheStatus.CACHED import io.github.mattpvaughn.chronicle.data.sources.plex.ICachedFileManager.CacheStatus.NOT_CACHED -import io.github.mattpvaughn.chronicle.data.sources.plex.model.getDuration import io.github.mattpvaughn.chronicle.util.* import io.github.mattpvaughn.chronicle.views.BottomSheetChooser import io.github.mattpvaughn.chronicle.views.BottomSheetChooser.BottomChooserListener @@ -42,6 +41,7 @@ class LibraryViewModel( private val trackRepository: ITrackRepository, private val prefsRepo: PrefsRepo, private val cachedFileManager: ICachedFileManager, + private val librarySyncRepository: LibrarySyncRepository, sharedPreferences: SharedPreferences ) : ViewModel() { @@ -51,16 +51,18 @@ class LibraryViewModel( private val trackRepository: ITrackRepository, private val prefsRepo: PrefsRepo, private val cachedFileManager: ICachedFileManager, - private val sharedPreferences: SharedPreferences + private val librarySyncRepository: LibrarySyncRepository, + private val sharedPreferences: SharedPreferences, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { + override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(LibraryViewModel::class.java)) { return LibraryViewModel( bookRepository, trackRepository, prefsRepo, cachedFileManager, - sharedPreferences + librarySyncRepository, + sharedPreferences, ) as T } else { throw IllegalArgumentException("Cannot instantiate $modelClass from LibraryViewModel.Factory") @@ -68,9 +70,7 @@ class LibraryViewModel( } } - private var _isRefreshing = MutableLiveData() - val isRefreshing: LiveData - get() = _isRefreshing + val isRefreshing = librarySyncRepository.isRefreshing private var _isSearchActive = MutableLiveData() val isSearchActive: LiveData @@ -172,7 +172,7 @@ class LibraryViewModel( val tracks: LiveData> get() = _tracks - private val cacheStatus = Transformations.map(tracks) { + private val cacheStatus = tracks.map { when { it.isEmpty() -> NOT_CACHED it.all { track -> track.cached } -> CACHED @@ -288,40 +288,8 @@ class LibraryViewModel( ) } - /** - * Pull most recent data from server and update repositories. - * - * Update book info for fields where child tracks serve as source of truth, like how - * [Audiobook.duration] serves as a delegate for [List.getDuration()] - * - * TODO: maybe refresh data in the repository whenever a query is made? repeating code b/w - * here and [HomeViewModel] - */ fun refreshData() { - viewModelScope.launch(Injector.get().unhandledExceptionHandler()) { - try { - _isRefreshing.postValue(true) - bookRepository.refreshDataPaginated() - trackRepository.refreshDataPaginated() - } catch (e: Throwable) { - _messageForUser.postEvent("Failed to refresh data") - } finally { - _isRefreshing.postValue(false) - } - - val audiobooks = bookRepository.getAllBooksAsync() - val tracks = trackRepository.getAllTracksAsync() - audiobooks.forEach { book -> - val tracksInAudiobook = tracks.filter { it.parentKey == book.id } - Timber.i("Book progress: ${tracksInAudiobook.getProgress()}") - bookRepository.updateTrackData( - bookId = book.id, - bookProgress = tracksInAudiobook.getProgress(), - bookDuration = tracksInAudiobook.getDuration(), - trackCount = tracksInAudiobook.size - ) - } - } + librarySyncRepository.refreshLibrary() } /** Shows/hides the filter/sort/view menu to the user. Show if [isVisible] is true, hide otherwise */ diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/login/LoginFragment.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/login/LoginFragment.kt index 4cdbfb2..83cd599 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/login/LoginFragment.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/login/LoginFragment.kt @@ -64,7 +64,7 @@ class LoginFragment : Fragment() { loginViewModel.isLoading.observe( viewLifecycleOwner, - Observer { isLoading -> + Observer { isLoading: Boolean -> if (isLoading) { binding.loading.visibility = View.VISIBLE } else { diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/login/LoginViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/login/LoginViewModel.kt index c288214..3e9932c 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/login/LoginViewModel.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/login/LoginViewModel.kt @@ -29,7 +29,7 @@ class LoginViewModel(private val plexLoginRepo: IPlexLoginRepo) : ViewModel() { private var hasLaunched = false - val isLoading = Transformations.map(plexLoginRepo.loginEvent) { loginState -> + val isLoading = plexLoginRepo.loginEvent.map { loginState -> return@map loginState.peekContent() == IPlexLoginRepo.LoginState.AWAITING_LOGIN_RESULTS } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/AudiobookMediaSessionCallback.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/AudiobookMediaSessionCallback.kt index 5c0fc07..596dd5d 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/AudiobookMediaSessionCallback.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/AudiobookMediaSessionCallback.kt @@ -12,10 +12,9 @@ import androidx.lifecycle.Observer import com.github.michaelbull.result.Ok import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource import io.github.mattpvaughn.chronicle.BuildConfig import io.github.mattpvaughn.chronicle.application.Injector import io.github.mattpvaughn.chronicle.application.MILLIS_PER_SECOND @@ -44,7 +43,7 @@ class AudiobookMediaSessionCallback @Inject constructor( private val prefsRepo: PrefsRepo, private val plexConfig: PlexConfig, private val mediaController: MediaControllerCompat, - private val dataSourceFactory: DefaultHttpDataSourceFactory, + private val dataSourceFactory: DefaultHttpDataSource.Factory, private val trackRepository: ITrackRepository, private val bookRepository: IBookRepository, private val serviceScope: CoroutineScope, @@ -56,7 +55,7 @@ class AudiobookMediaSessionCallback @Inject constructor( private val appContext: Context, private val currentlyPlaying: CurrentlyPlaying, private val progressUpdater: ProgressUpdater, - defaultPlayer: SimpleExoPlayer + defaultPlayer: ExoPlayer ) : MediaSessionCompat.Callback() { // Default to ExoPlayer to prevent having a nullable field @@ -305,14 +304,16 @@ class AudiobookMediaSessionCallback @Inject constructor( // Refresh auth token in [dataSourceFactory] in case the server has changed without // the service being recreated - val props = dataSourceFactory.defaultRequestProperties - props.set( - "X-Plex-Token", - plexPrefsRepo.server?.accessToken - ?: plexPrefsRepo.user?.authToken - ?: plexPrefsRepo.accountAuthToken + dataSourceFactory.setDefaultRequestProperties( + mapOf( + "X-Plex-Token" to ( + plexPrefsRepo.server?.accessToken + ?: plexPrefsRepo.user?.authToken + ?: plexPrefsRepo.accountAuthToken + ) + ) ) - val factory = DefaultDataSourceFactory(appContext, dataSourceFactory) + val factory = DefaultDataSource.Factory(appContext, dataSourceFactory) when (player) { is ExoPlayer -> { val mediaSource = metadataList.toMediaSource(plexPrefsRepo, factory) diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/AudiobookPlaybackPreparer.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/AudiobookPlaybackPreparer.kt index 808d486..31319ae 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/AudiobookPlaybackPreparer.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/AudiobookPlaybackPreparer.kt @@ -6,7 +6,6 @@ import android.os.ResultReceiver import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat.* -import com.google.android.exoplayer2.ControlDispatcher import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import io.github.mattpvaughn.chronicle.data.model.MediaItemTrack @@ -22,7 +21,7 @@ class AudiobookPlaybackPreparer @Inject constructor( private val mediaSessionCallback: MediaSessionCompat.Callback ) : MediaSessionConnector.PlaybackPreparer { - override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle) { + override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { mediaSource.whenReady { if (playWhenReady) { mediaSessionCallback.onPlayFromSearch(query, extras) @@ -34,7 +33,6 @@ class AudiobookPlaybackPreparer @Inject constructor( override fun onCommand( player: Player, - controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver? @@ -47,7 +45,7 @@ class AudiobookPlaybackPreparer @Inject constructor( ACTION_PREPARE_FROM_MEDIA_ID or ACTION_PLAY_FROM_MEDIA_ID or ACTION_PREPARE_FROM_SEARCH or ACTION_PLAY_FROM_SEARCH - override fun onPrepareFromMediaId(bookId: String, playWhenReady: Boolean, extras: Bundle) { + override fun onPrepareFromMediaId(bookId: String, playWhenReady: Boolean, extras: Bundle?) { mediaSource.whenReady { if (playWhenReady) { mediaSessionCallback.onPlayFromMediaId(bookId, extras) @@ -57,7 +55,7 @@ class AudiobookPlaybackPreparer @Inject constructor( } } - override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle) = Unit + override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit override fun onPrepare(playWhenReady: Boolean) = Unit } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/CustomActions.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/CustomActions.kt index 05ce212..7eb5af0 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/CustomActions.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/CustomActions.kt @@ -8,7 +8,6 @@ import android.view.KeyEvent.KEYCODE_MEDIA_NEXT import android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS import android.view.KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD import android.view.KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD -import com.google.android.exoplayer2.ControlDispatcher import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider import io.github.mattpvaughn.chronicle.R @@ -118,7 +117,6 @@ class SimpleCustomActionProvider( override fun onCustomAction( player: Player, - controlDispatcher: ControlDispatcher, action: String, extras: Bundle? ) { diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/MediaMetadataCompatExt.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/MediaMetadataCompatExt.kt index f59ed9d..5c78e1a 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/MediaMetadataCompatExt.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/MediaMetadataCompatExt.kt @@ -22,7 +22,6 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import com.google.android.exoplayer2.source.ConcatenatingMediaSource -import com.google.android.exoplayer2.source.ExtractorMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.upstream.DataSource import io.github.mattpvaughn.chronicle.data.sources.plex.PlexPrefsRepo @@ -270,10 +269,15 @@ inline val MediaMetadataCompat.fullDescription: MediaDescriptionCompat * * For convenience, place the [MediaDescriptionCompat] into the tag so it can be retrieved later. */ -fun MediaMetadataCompat.toMediaSource(dataSourceFactory: DataSource.Factory): ProgressiveMediaSource = - ProgressiveMediaSource.Factory(dataSourceFactory) - .setTag(fullDescription) - .createMediaSource(mediaUri) +fun MediaMetadataCompat.toMediaSource(dataSourceFactory: DataSource.Factory): ProgressiveMediaSource { + return ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource( + com.google.android.exoplayer2.MediaItem.Builder() + .setTag(fullDescription) + .setUri(mediaUri) + .build() + ) +} fun MediaMetadataCompat.describe(): String { return "${this.title}, ${this.artist}, ${this.displayTitle}" diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/MediaPlayerService.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/MediaPlayerService.kt index 7018087..632a691 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/MediaPlayerService.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/MediaPlayerService.kt @@ -3,6 +3,7 @@ package io.github.mattpvaughn.chronicle.features.player import android.app.Notification import android.app.PendingIntent import android.content.* +import android.os.Build import android.os.Bundle import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.session.MediaControllerCompat @@ -42,8 +43,8 @@ import io.github.mattpvaughn.chronicle.util.PackageValidator import kotlinx.coroutines.* import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime -import kotlin.time.seconds /** The service responsible for media playback, notification */ @ExperimentalCoroutinesApi @@ -85,7 +86,7 @@ class MediaPlayerService : lateinit var queueNavigator: QueueNavigator @Inject - lateinit var exoPlayer: SimpleExoPlayer + lateinit var exoPlayer: ExoPlayer @Inject lateinit var currentlyPlaying: CurrentlyPlaying @@ -146,7 +147,7 @@ class MediaPlayerService : * * @see DefaultLoadControl.Builder.setBufferDurationsMs */ - val EXOPLAYER_BACK_BUFFER_DURATION_MILLIS: Int = 120.seconds.toLongMilliseconds().toInt() + val EXOPLAYER_BACK_BUFFER_DURATION_MILLIS: Int = 120.seconds.inWholeMilliseconds.toInt() /** * Exoplayer min-buffer (the minimum millis of buffer which exo will attempt to keep in @@ -154,14 +155,14 @@ class MediaPlayerService : * * @see DefaultLoadControl.Builder.setBufferDurationsMs */ - val EXOPLAYER_MIN_BUFFER_DURATION_MILLIS: Int = 10.seconds.toLongMilliseconds().toInt() + val EXOPLAYER_MIN_BUFFER_DURATION_MILLIS: Int = 10.seconds.inWholeMilliseconds.toInt() /** * Exoplayer max-buffer (the maximum duration of buffer which Exoplayer will store in memory) * * @see DefaultLoadControl.Builder.setBufferDurationsMs */ - val EXOPLAYER_MAX_BUFFER_DURATION_MILLIS: Int = 360.seconds.toLongMilliseconds().toInt() + val EXOPLAYER_MAX_BUFFER_DURATION_MILLIS: Int = 360.seconds.inWholeMilliseconds.toInt() } @Inject @@ -190,7 +191,7 @@ class MediaPlayerService : Timber.i("Service created! $this") - updateAudioAttrs(simpleExoPlayer = exoPlayer) + updateAudioAttrs(exoPlayer = exoPlayer) prefsRepo.registerPrefsListener(prefsListener) @@ -212,7 +213,7 @@ class MediaPlayerService : ) mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setPlaybackPreparer(playbackPreparer) - mediaSessionConnector.setMediaButtonEventHandler { _, _, mediaButtonEvent -> + mediaSessionConnector.setMediaButtonEventHandler { _, mediaButtonEvent -> mediaSessionCallback.onMediaButtonEvent(mediaButtonEvent) } @@ -252,8 +253,14 @@ class MediaPlayerService : override fun onReceive(context: Context?, intent: Intent?) { if (intent != null) { val durationMillis = intent.getLongExtra(ARG_SLEEP_TIMER_DURATION_MILLIS, 0L) - val action = intent.getSerializableExtra(ARG_SLEEP_TIMER_ACTION) as SleepTimerAction - sleepTimer.handleAction(action, durationMillis) + val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getSerializableExtra(ARG_SLEEP_TIMER_ACTION, SleepTimerAction::class.java) + } else { + intent.getSerializableExtra(ARG_SLEEP_TIMER_ACTION) as? SleepTimerAction + } + if (action != null) { + sleepTimer.handleAction(action, durationMillis) + } } } } @@ -279,10 +286,10 @@ class MediaPlayerService : } } - private fun updateAudioAttrs(simpleExoPlayer: SimpleExoPlayer) { - simpleExoPlayer.setAudioAttributes( + private fun updateAudioAttrs(exoPlayer: ExoPlayer) { + exoPlayer.setAudioAttributes( AudioAttributes.Builder() - .setContentType(if (prefsRepo.pauseOnFocusLost) CONTENT_TYPE_SPEECH else CONTENT_TYPE_MUSIC) + .setContentType(if (prefsRepo.pauseOnFocusLost) AUDIO_CONTENT_TYPE_SPEECH else AUDIO_CONTENT_TYPE_MUSIC) .setUsage(USAGE_MEDIA) .build(), true @@ -328,16 +335,16 @@ class MediaPlayerService : private fun invalidatePlaybackParams() { Timber.i("Playback params: speed = ${prefsRepo.playbackSpeed}, skip silence = ${prefsRepo.skipSilence}") - currentPlayer?.setPlaybackParameters( - PlaybackParameters(prefsRepo.playbackSpeed, 1.0f, prefsRepo.skipSilence) - ) + currentPlayer?.playbackParameters = PlaybackParameters(prefsRepo.playbackSpeed, 1.0f) + (currentPlayer as? ExoPlayer)?.skipSilenceEnabled = prefsRepo.skipSilence } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) // Ensures that players will not block being removed as a foreground service - exoPlayer.stop(true) + exoPlayer.stop() + exoPlayer.clearMediaItems() } override fun onDestroy() { @@ -378,7 +385,7 @@ class MediaPlayerService : this@MediaPlayerService, KEYCODE_MEDIA_PLAY, intent, - 0 + PendingIntent.FLAG_IMMUTABLE ) ) } @@ -573,9 +580,9 @@ class MediaPlayerService : } } - private val playerEventListener = object : Player.EventListener { + private val playerEventListener = object : Player.Listener { - override fun onPlayerError(error: ExoPlaybackException) { + override fun onPlayerError(error: PlaybackException) { Timber.e("Exoplayer playback error: $error") val errorIntent = Intent(ACTION_PLAYBACK_ERROR) errorIntent.putExtra(PLAYBACK_ERROR_MESSAGE, error.message) @@ -583,10 +590,10 @@ class MediaPlayerService : super.onPlayerError(error) } - override fun onPositionDiscontinuity(reason: Int) { - super.onPositionDiscontinuity(reason) + override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) serviceScope.launch(Injector.get().unhandledExceptionHandler()) { - if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) { + if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { Timber.i("Playing next track") // Update track progress val trackId = mediaController.metadata.id @@ -612,8 +619,8 @@ class MediaPlayerService : } } - override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { - super.onPlayerStateChanged(playWhenReady, playbackState) + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) if (playbackState != PlaybackStateCompat.STATE_ERROR) { // clear errors if playback is proceeding correctly mediaSessionConnector.setCustomErrorMessage(null) diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/NotificationBuilder.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/NotificationBuilder.kt index 68cbb50..8c5f692 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/NotificationBuilder.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/NotificationBuilder.kt @@ -122,7 +122,7 @@ class NotificationBuilder @Inject constructor( ?: "io.github.mattpvaughn.chronicle.features.player.MediaPlayerService" ) intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) - return PendingIntent.getService(context, keycode, intent, 0) + return PendingIntent.getService(context, keycode, intent, PendingIntent.FLAG_IMMUTABLE) } private val stopPendingIntent = makePendingIntent(KEYCODE_MEDIA_STOP) @@ -140,7 +140,7 @@ class NotificationBuilder @Inject constructor( context, REQUEST_CODE_OPEN_APP_TO_CURRENTLY_PLAYING, intent, - 0 + PendingIntent.FLAG_IMMUTABLE ) } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/ProgressUpdater.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/ProgressUpdater.kt index e888a32..0bc0ccf 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/ProgressUpdater.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/ProgressUpdater.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.launch import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject -import kotlin.time.minutes +import kotlin.time.Duration.Companion.minutes /** * Responsible for updating playback progress of the current book/track to the local DB and to the @@ -55,7 +55,7 @@ interface ProgressUpdater { fun cancel() companion object { - val BOOK_FINISHED_END_OFFSET_MILLIS = 2.minutes.toLongMilliseconds() + val BOOK_FINISHED_END_OFFSET_MILLIS = 2.minutes.inWholeMilliseconds /** * The frequency which the remote server is updated at: once for every [NETWORK_CALL_FREQUENCY] @@ -206,7 +206,7 @@ class SimpleProgressUpdater @Inject constructor( .setConstraints(syncWorkerConstraints) .setBackoffCriteria( BackoffPolicy.LINEAR, - OneTimeWorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, + WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, TimeUnit.MILLISECONDS ) .build() diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/SleepTimer.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/SleepTimer.kt index 67fd220..d587259 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/SleepTimer.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/player/SleepTimer.kt @@ -4,6 +4,7 @@ import android.app.Service import android.hardware.SensorManager import android.media.ToneGenerator import android.os.Handler +import android.os.Looper import android.support.v4.media.session.MediaControllerCompat import android.widget.Toast import com.squareup.seismic.ShakeDetector @@ -53,7 +54,7 @@ class SimpleSleepTimer @Inject constructor( private val sleepTimerUpdateFrequencyMs = 1000L private var sleepTimeRemaining = 0L - private val sleepTimerHandler = Handler() + private val sleepTimerHandler = Handler(Looper.getMainLooper()) private val updateSleepTimerAction = { start(false) } private var isActive: Boolean = false private val shakeToSnoozeDurationMs = 5 * 60 * 1000L @@ -104,7 +105,7 @@ class SimpleSleepTimer @Inject constructor( return } if (justStarting) { - shakeDetector.start(sensorManager) + shakeDetector.start(sensorManager, SensorManager.SENSOR_DELAY_GAME) } Timber.i("Sleep timer tick: $sleepTimeRemaining ms remaining") if (sleepTimeRemaining > 0L) { diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/features/settings/SettingsViewModel.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/features/settings/SettingsViewModel.kt index 742ee4e..ea9eefd 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/features/settings/SettingsViewModel.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/features/settings/SettingsViewModel.kt @@ -11,6 +11,7 @@ import io.github.mattpvaughn.chronicle.BuildConfig import io.github.mattpvaughn.chronicle.R import io.github.mattpvaughn.chronicle.application.FEATURE_FLAG_IS_AUTO_ENABLED import io.github.mattpvaughn.chronicle.application.Injector +import io.github.mattpvaughn.chronicle.data.local.CollectionsRepository import io.github.mattpvaughn.chronicle.data.local.IBookRepository import io.github.mattpvaughn.chronicle.data.local.ITrackRepository import io.github.mattpvaughn.chronicle.data.local.PrefsRepo @@ -53,6 +54,7 @@ class SettingsViewModel( private val plexConfig: PlexConfig, private val workManager: WorkManager, private val plexPrefs: PlexPrefsRepo, + private val collectionsRepository: CollectionsRepository ) : ViewModel() { @Suppress("UNCHECKED_CAST") @@ -66,8 +68,9 @@ class SettingsViewModel( private val plexConfig: PlexConfig, private val workManager: WorkManager, private val plexPrefs: PlexPrefsRepo, + private val collectionsRepository: CollectionsRepository ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { + override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { return SettingsViewModel( bookRepository = bookRepository, @@ -78,7 +81,8 @@ class SettingsViewModel( cachedFileManager = cachedFileManager, plexConfig = plexConfig, workManager = workManager, - plexPrefs = plexPrefs + plexPrefs = plexPrefs, + collectionsRepository = collectionsRepository ) as T } else { throw IllegalArgumentException("Cannot instantiate $modelClass from SettingsViewModel.Factory") @@ -793,6 +797,7 @@ class SettingsViewModel( withContext(Dispatchers.IO) { bookRepository.clear() trackRepository.clear() + collectionsRepository.clear() } mediaServiceConnection.transportControls?.stop() when (navigateTo) { diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/ActivityComponent.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/ActivityComponent.kt index 3cbf7fb..b940923 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/ActivityComponent.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/ActivityComponent.kt @@ -6,6 +6,8 @@ import io.github.mattpvaughn.chronicle.application.MainActivity import io.github.mattpvaughn.chronicle.application.MainActivityViewModel import io.github.mattpvaughn.chronicle.features.bookdetails.AudiobookDetailsFragment import io.github.mattpvaughn.chronicle.features.bookdetails.AudiobookDetailsViewModel +import io.github.mattpvaughn.chronicle.features.collections.CollectionDetailsFragment +import io.github.mattpvaughn.chronicle.features.collections.CollectionsFragment import io.github.mattpvaughn.chronicle.features.currentlyplaying.CurrentlyPlayingFragment import io.github.mattpvaughn.chronicle.features.currentlyplaying.CurrentlyPlayingViewModel import io.github.mattpvaughn.chronicle.features.home.HomeFragment @@ -37,6 +39,8 @@ interface ActivityComponent { fun inject(detailsFragment: AudiobookDetailsFragment) fun inject(homeFragment: HomeFragment) fun inject(settingsFragment: SettingsFragment) + fun inject(collectionsFragment: CollectionsFragment) + fun inject(collectionDetailsFragment: CollectionDetailsFragment) fun inject(currentlyPlayingFragment: CurrentlyPlayingFragment) fun inject(modalBottomSheetSpeedChooser: ModalBottomSheetSpeedChooser) } diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/AppComponent.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/AppComponent.kt index c07a284..4d37154 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/AppComponent.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/AppComponent.kt @@ -30,12 +30,16 @@ interface AppComponent { fun sharedPrefs(): SharedPreferences fun trackDao(): TrackDao fun bookDao(): BookDao + fun collectionsDao(): CollectionsDao fun moshi(): Moshi fun plexLoginRepo(): IPlexLoginRepo fun plexPrefs(): PlexPrefsRepo fun prefsRepo(): PrefsRepo fun trackRepo(): ITrackRepository + fun librarySyncRepo(): LibrarySyncRepository + fun collectionsRepo(): CollectionsRepository fun bookRepo(): IBookRepository + fun bookRepos(): BookRepository fun workManager(): WorkManager fun unhandledExceptionHandler(): CoroutineExceptionHandler fun plexConfig(): PlexConfig diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/ServiceComponent.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/ServiceComponent.kt index 52b5139..2433f2c 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/ServiceComponent.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/components/ServiceComponent.kt @@ -7,7 +7,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource import dagger.Component import io.github.mattpvaughn.chronicle.data.sources.plex.PlexMediaRepository import io.github.mattpvaughn.chronicle.data.sources.plex.PlexMediaSource @@ -34,7 +34,7 @@ interface ServiceComponent { fun mediaSessionConnector(): MediaSessionConnector fun serviceScope(): CoroutineScope fun serviceController(): ServiceController - fun plexDataSourceFactory(): DefaultHttpDataSourceFactory + fun plexDataSourceFactory(): DefaultHttpDataSource.Factory fun packageValidator(): PackageValidator fun foregroundServiceController(): ForegroundServiceController fun trackListManager(): TrackListStateManager diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/modules/AppModule.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/modules/AppModule.kt index a309923..402c127 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/modules/AppModule.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/modules/AppModule.kt @@ -76,6 +76,10 @@ class AppModule(private val app: Application) { @Singleton fun provideBookRepo(bookRepository: BookRepository): IBookRepository = bookRepository + @Provides + @Singleton + fun provideCollectionsDao(): CollectionsDao = getCollectionsDatabase(app.applicationContext).collectionsDao + @Provides @Singleton fun provideInternalDeviceDirs(): File = app.applicationContext.filesDir diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/modules/ServiceModule.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/modules/ServiceModule.kt index 1435e3d..b448d82 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/injection/modules/ServiceModule.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/injection/modules/ServiceModule.kt @@ -15,9 +15,8 @@ import androidx.core.app.NotificationManagerCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.google.android.exoplayer2.DefaultLoadControl import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource import com.google.android.exoplayer2.util.Util import dagger.Module import dagger.Provides @@ -57,7 +56,7 @@ class ServiceModule(private val service: MediaPlayerService) { @Provides @ServiceScope - fun simpleExoPlayer(): SimpleExoPlayer = SimpleExoPlayer.Builder(service).setLoadControl( + fun exoPlayer(): ExoPlayer = ExoPlayer.Builder(service).setLoadControl( // increase buffer size across the board as ExoPlayer defaults are set for video DefaultLoadControl.Builder().setBackBuffer(EXOPLAYER_BACK_BUFFER_DURATION_MILLIS, true) .setBufferDurationsMs( @@ -66,13 +65,9 @@ class ServiceModule(private val service: MediaPlayerService) { DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS ) - .createDefaultLoadControl() + .build() ).build() - @Provides - @ServiceScope - fun exoPlayer(simpleExoPlayer: SimpleExoPlayer): ExoPlayer = simpleExoPlayer - @Provides @ServiceScope fun pendingIntent(): PendingIntent = @@ -82,7 +77,7 @@ class ServiceModule(private val service: MediaPlayerService) { service, MainActivity.REQUEST_CODE_OPEN_APP_TO_CURRENTLY_PLAYING, sessionIntent, - 0 + PendingIntent.FLAG_IMMUTABLE ) } @@ -133,22 +128,26 @@ class ServiceModule(private val service: MediaPlayerService) { @Provides @ServiceScope - fun plexDataSourceFactory(plexPrefs: PlexPrefsRepo): DefaultHttpDataSourceFactory { - val dataSourceFactory = DefaultHttpDataSourceFactory(Util.getUserAgent(service, APP_NAME)) - - val props = dataSourceFactory.defaultRequestProperties - props.set("X-Plex-Platform", "Android") - props.set("X-Plex-Provides", "player") - props.set("X-Plex_Client-Name", APP_NAME) - props.set("X-Plex-Client-Identifier", plexPrefs.uuid) - props.set("X-Plex-Version", BuildConfig.VERSION_NAME) - props.set("X-Plex-Product", APP_NAME) - props.set("X-Plex-Platform-Version", Build.VERSION.RELEASE) - props.set("X-Plex-Device", Build.MODEL) - props.set("X-Plex-Device-Name", Build.MODEL) - props.set( - "X-Plex-Token", - plexPrefs.server?.accessToken ?: plexPrefs.user?.authToken ?: plexPrefs.accountAuthToken + fun plexDataSourceFactory(plexPrefs: PlexPrefsRepo): DefaultHttpDataSource.Factory { + val dataSourceFactory = DefaultHttpDataSource.Factory() + dataSourceFactory.setUserAgent(Util.getUserAgent(service, APP_NAME)) + + dataSourceFactory.setDefaultRequestProperties( + mapOf( + "X-Plex-Platform" to "Android", + "X-Plex-Provides" to "player", + "X-Plex_Client-Name" to APP_NAME, + "X-Plex-Client-Identifier" to plexPrefs.uuid, + "X-Plex-Version" to BuildConfig.VERSION_NAME, + "X-Plex-Product" to APP_NAME, + "X-Plex-Platform-Version" to Build.VERSION.RELEASE, + "X-Plex-Device" to Build.MODEL, + "X-Plex-Device-Name" to Build.MODEL, + "X-Plex-Token" to ( + plexPrefs.server?.accessToken ?: plexPrefs.user?.authToken + ?: plexPrefs.accountAuthToken + ) + ) ) return dataSourceFactory diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/navigation/Navigator.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/navigation/Navigator.kt index 1428ab2..2d1c1b9 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/navigation/Navigator.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/navigation/Navigator.kt @@ -12,6 +12,8 @@ import io.github.mattpvaughn.chronicle.features.bookdetails.AudiobookDetailsFrag import io.github.mattpvaughn.chronicle.features.bookdetails.AudiobookDetailsFragment.Companion.ARG_AUDIOBOOK_ID import io.github.mattpvaughn.chronicle.features.bookdetails.AudiobookDetailsFragment.Companion.ARG_AUDIOBOOK_TITLE import io.github.mattpvaughn.chronicle.features.bookdetails.AudiobookDetailsFragment.Companion.ARG_IS_AUDIOBOOK_CACHED +import io.github.mattpvaughn.chronicle.features.collections.CollectionDetailsFragment +import io.github.mattpvaughn.chronicle.features.collections.CollectionsFragment import io.github.mattpvaughn.chronicle.features.home.HomeFragment import io.github.mattpvaughn.chronicle.features.library.LibraryFragment import io.github.mattpvaughn.chronicle.features.login.ChooseLibraryFragment @@ -123,6 +125,12 @@ class Navigator @Inject constructor( fragmentManager.beginTransaction().replace(R.id.fragNavHost, libraryFragment).commit() } + fun showCollections() { + clearBackStack() + val collectionsFragment = CollectionsFragment.newInstance() + fragmentManager.beginTransaction().replace(R.id.fragNavHost, collectionsFragment).commit() + } + fun showSettings() { clearBackStack() val settingsFragment = SettingsFragment.newInstance() @@ -144,6 +152,14 @@ class Navigator @Inject constructor( .commit() } + fun showCollectionDetails(collectionId: Int) { + val collectionDetails = CollectionDetailsFragment.newInstance(collectionId) + fragmentManager.beginTransaction() + .replace(R.id.fragNavHost, collectionDetails) + .addToBackStack(CollectionDetailsFragment.TAG) + .commit() + } + /** Handle back presses. Return a boolean indicating whether the back press event was handled */ fun onBackPressed(): Boolean { val wasBackPressHandled = when { diff --git a/app/src/main/java/io/github/mattpvaughn/chronicle/util/KotlinExt.kt b/app/src/main/java/io/github/mattpvaughn/chronicle/util/KotlinExt.kt index ab679da..1f3c946 100644 --- a/app/src/main/java/io/github/mattpvaughn/chronicle/util/KotlinExt.kt +++ b/app/src/main/java/io/github/mattpvaughn/chronicle/util/KotlinExt.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.Observer fun LiveData.observeOnce(observer: Observer) { observeForever(object : Observer { - override fun onChanged(t: T?) { + override fun onChanged(t: T) { observer.onChanged(t) removeObserver(this) } diff --git a/app/src/main/res/drawable/ic_collections.xml b/app/src/main/res/drawable/ic_collections.xml new file mode 100644 index 0000000..b2b971d --- /dev/null +++ b/app/src/main/res/drawable/ic_collections.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_collection_details.xml b/app/src/main/res/layout/fragment_collection_details.xml new file mode 100644 index 0000000..ab85af0 --- /dev/null +++ b/app/src/main/res/layout/fragment_collection_details.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_collections.xml b/app/src/main/res/layout/fragment_collections.xml new file mode 100644 index 0000000..b18201e --- /dev/null +++ b/app/src/main/res/layout/fragment_collections.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +