From 00c84fa9d50d4696051ccd26547c89bbd4123692 Mon Sep 17 00:00:00 2001 From: Jarne Demeulemeester Date: Sun, 7 May 2023 16:05:40 +0200 Subject: [PATCH] Items and downloads rework (#329) * refactor WIP: stop using `BaseItemDto` but use custom items specific to Findroid This will make it easier to support downloaded items * refactor: split `MediaInfoFragment` into `MovieFragment` and `ShowFragment` * feat: add download icons to items * feat WIP: download movies * feat: download movie and play local file * fix: remove `VideoVersionDialogFragment` from `ShowFragment` * feat: select which version you want to download * feat: delete downloaded movie * feat: download progress indicator * refactor: rename JellyfinItems to FindroidItems * feat: offline mode (movies only) * feat: offline mode card * feat: download external files * feat: toggle played on downloads * feat: convert intros to `FindroidIntro` * refactor: add itemId and sourceId to external downloaded subtitle filenames * refactor: simplify `onMediaItemTransition` * refactor: clean up some player item logic * feat: download trickPlay data * refactor: downloading of item to only require the item and a source id * fix: external subtitle title * feat: add `DownloadsFragment` * feat: download episodes * fix: cascade deletion if last item * feat: download intro timestamps * feat WIP: add storage activity * feat: user data in separate table * feat: add buttons to season fragment * fix: improve responsiveness of the watched and favorite buttons * fix: move `ic_database.xml` to main * perf: optimize home fragment by limiting the number of items * fix: database improvements - use compound primary key for FindroidUserDataDto instead of id - set played to false when playback percentage is below 90% - capitalize SQL keywords - update favorite in userdata - set primary key of TrickPlayManifestDto to itemId - prepare to sync data back to server * feat: sync playback progress This includes playback position, played and favorite * fix: use non-transitive r classes * lint: ktlint fix * refactor: centralize item buttons in `item_actions.xml` * feat: show intermediate progress when progress is less than 5 Also remove delete button from item_actions.xml * feat: remove intros * feat: check available storage space before downloading * fix: trailer button * refactor: make indexNumberEnd nullable * feat: add offline mode toggle in settings * fix: download over mobile data and roaming * feat: immediately show spinner when tapping download * revert: season fragment buttons * feat: snackbar in downloads fragment This snackbar is displayed when there is no connection to the server but the app is not in Offline Mode (Offline Mode is required to play content when the server is unavailable) * refactor: make onReceive arguments non nullable * fix: handle download finished / failed when BroadcastReceiver does not work * fix: download multiple episodes * feat: download to external storage (SD card) * fix: reset download button when dialog is dismissed * feat(offline): show "continue watching" episodes on home * fix: watch progress bar on episode item in season * feat(offline): next up items * lint: fix some linting issues * lint: fix some linting issues * lint: fix some linting issues * feat: remove StorageActivity StorageActivity is not ready yet and out of scope for this PR * fix: collection types that are not known crash the media fragment * fix: downloading trick play data * fix: sort downloaded items * fix: navigate back if item is deleted instead of showing error Navigate back based on NullPointerException in loadData method of viewmodels. This may not be the best approach but it works well enough. Navigating back from BottomSheetFragment does not trigger onResume of previous fragment which in turn does not refresh its contents. * fix: play from local storage instead of server when downloaded * fix: missing items * fix: `SyncWorker` using the app JellyfinApi instance instead of it's own * fix: only show downloaded items when navigating from `DownloadsFragment` * fix: make chips horizontal scrollable * feat: migrate database (retain) and downloads (wipe) Also add indexes on seriesId and seasonId * fix: remove temp testing in downloadsMigrated * lint: fix some linting issues * fix: add error handling to downloading item * feat: add "Preparing download" dialog to make sure the user waits for the download to start * refactor: first show dialog then start downloading * fix: add error handling to user configuration in `PlayerViewModel` * feat: allow downloads to be cancelled * fix: "View details" is cut off when text is too long * lint: fix indent --- app/phone/build.gradle.kts | 2 + app/phone/src/main/AndroidManifest.xml | 21 +- .../dev/jdtech/jellyfin/BaseApplication.kt | 12 +- .../dev/jdtech/jellyfin/BindingAdapters.kt | 56 +- .../java/dev/jdtech/jellyfin/MainActivity.kt | 60 +- .../adapters/CollectionListAdapter.kt | 16 +- .../adapters/DownloadEpisodeListAdapter.kt | 116 --- .../adapters/DownloadSeriesListAdapter.kt | 71 -- .../adapters/DownloadViewItemListAdapter.kt | 66 -- .../jellyfin/adapters/DownloadsListAdapter.kt | 64 -- .../jellyfin/adapters/EpisodeListAdapter.kt | 40 +- .../adapters/HomeEpisodeListAdapter.kt | 54 +- .../jellyfin/adapters/ViewItemListAdapter.kt | 29 +- .../adapters/ViewItemPagingAdapter.kt | 29 +- .../jellyfin/adapters/ViewListAdapter.kt | 31 +- .../dialogs/StorageSelectionDialog.kt | 29 + .../jellyfin/fragments/CollectionFragment.kt | 51 +- .../jellyfin/fragments/DownloadFragment.kt | 117 --- .../fragments/DownloadSeriesFragment.kt | 101 --- .../jellyfin/fragments/DownloadsFragment.kt | 126 +++ .../fragments/EpisodeBottomSheetFragment.kt | 369 +++++--- .../jellyfin/fragments/FavoriteFragment.kt | 51 +- .../jdtech/jellyfin/fragments/HomeFragment.kt | 74 +- .../jellyfin/fragments/LibraryFragment.kt | 53 +- .../jellyfin/fragments/MediaFragment.kt | 6 +- .../jellyfin/fragments/MediaInfoFragment.kt | 451 ---------- .../jellyfin/fragments/MovieFragment.kt | 511 +++++++++++ .../fragments/PersonDetailFragment.kt | 33 +- .../fragments/SearchResultFragment.kt | 51 +- .../jellyfin/fragments/SeasonFragment.kt | 59 +- .../jellyfin/fragments/SettingsFragment.kt | 6 + .../jdtech/jellyfin/fragments/ShowFragment.kt | 348 ++++++++ ...gment_media_info.xml => fragment_show.xml} | 166 +--- app/phone/src/main/res/layout/base_item.xml | 77 +- .../src/main/res/layout/card_offline.xml | 47 + .../src/main/res/layout/collection_item.xml | 4 +- .../main/res/layout/episode_bottom_sheet.xml | 120 +-- .../src/main/res/layout/episode_item.xml | 92 +- app/phone/src/main/res/layout/error_panel.xml | 8 +- .../src/main/res/layout/fragment_download.xml | 48 - .../res/layout/fragment_download_series.xml | 36 - .../main/res/layout/fragment_downloads.xml | 54 ++ .../src/main/res/layout/fragment_movie.xml | 460 ++++++++++ ...gment_media_info.xml => fragment_show.xml} | 173 +--- .../src/main/res/layout/home_episode_item.xml | 23 +- .../src/main/res/layout/item_actions.xml | 104 +++ .../src/main/res/layout/next_up_section.xml | 1 - .../res/layout/preparing_download_dialog.xml | 13 + .../src/main/res/layout/season_header.xml | 2 +- .../main/res/navigation/app_navigation.xml | 186 ++-- core/build.gradle.kts | 3 + .../jellyfin/database/ServerDatabase.kt | 14 - .../jellyfin/database/ServerDatabaseDao.kt | 79 -- .../java/dev/jdtech/jellyfin/di/ApiModule.kt | 21 +- .../dev/jdtech/jellyfin/di/DatabaseModule.kt | 16 - .../jdtech/jellyfin/di/DownloaderModule.kt | 28 + .../jdtech/jellyfin/di/RepositoryModule.kt | 38 +- .../dev/jdtech/jellyfin/models/EpisodeItem.kt | 12 +- .../jdtech/jellyfin/models/FavoriteSection.kt | 4 +- .../dev/jdtech/jellyfin/models/HomeItem.kt | 4 + .../dev/jdtech/jellyfin/models/HomeSection.kt | 5 +- .../java/dev/jdtech/jellyfin/models/UiText.kt | 2 +- .../java/dev/jdtech/jellyfin/models/View.kt | 3 +- .../jdtech/jellyfin/utils/CoreExtensions.kt | 8 + .../jdtech/jellyfin/utils/DownloadReceiver.kt | 80 ++ .../dev/jdtech/jellyfin/utils/Downloader.kt | 19 + .../jdtech/jellyfin/utils/DownloaderImpl.kt | 242 ++++++ .../viewmodels/CollectionViewModel.kt | 10 +- .../viewmodels/DownloadSeriesViewModel.kt | 46 - .../jellyfin/viewmodels/DownloadViewModel.kt | 89 -- .../jellyfin/viewmodels/DownloadsViewModel.kt | 86 ++ .../viewmodels/EpisodeBottomSheetViewModel.kt | 253 +++--- .../jellyfin/viewmodels/FavoriteViewModel.kt | 15 +- .../jellyfin/viewmodels/HomeViewModel.kt | 39 +- .../jellyfin/viewmodels/LibraryViewModel.kt | 4 +- .../jellyfin/viewmodels/MediaInfoViewModel.kt | 428 --------- .../jellyfin/viewmodels/MediaViewModel.kt | 8 +- .../jellyfin/viewmodels/MovieViewModel.kt | 385 ++++++++ .../viewmodels/PersonDetailViewModel.kt | 10 +- .../viewmodels/SearchResultViewModel.kt | 10 +- .../jellyfin/viewmodels/SeasonViewModel.kt | 28 +- .../jellyfin/viewmodels/ShowViewModel.kt | 213 +++++ .../jellyfin/viewmodels/StorageViewModel.kt | 106 +++ .../dev/jdtech/jellyfin/work/SyncWorker.kt | 88 ++ core/src/main/res/drawable/ic_database.xml | 28 + core/src/main/res/drawable/ic_server_off.xml | 49 ++ core/src/main/res/menu/bottom_nav_menu.xml | 2 +- .../main/res/menu/bottom_nav_menu_offline.xml | 14 + core/src/main/res/values/strings.xml | 18 + core/src/main/res/xml/fragment_settings.xml | 8 +- .../res/xml/fragment_settings_downloads.xml | 4 +- data/build.gradle.kts | 10 + .../2.json | 166 ++++ .../3.json | 819 ++++++++++++++++++ .../jdtech/jellyfin/database/Converters.kt | 28 + .../jellyfin/database/ServerDatabase.kt | 30 + .../jellyfin/database/ServerDatabaseDao.kt | 263 ++++++ .../jdtech/jellyfin/models/CollectionType.kt | 2 + .../jdtech/jellyfin/models/FindroidBoxSet.kt | 26 + .../jellyfin/models/FindroidCollection.kt | 33 + .../jdtech/jellyfin/models/FindroidEpisode.kt | 91 ++ .../jellyfin/models/FindroidEpisodeDto.kt | 64 ++ .../jdtech/jellyfin/models/FindroidItem.kt | 44 + .../jellyfin/models/FindroidMediaStream.kt | 54 ++ .../jellyfin/models/FindroidMediaStreamDto.kt | 47 + .../jdtech/jellyfin/models/FindroidMovie.kt | 92 ++ .../jellyfin/models/FindroidMovieDto.kt | 40 + .../jdtech/jellyfin/models/FindroidSeason.kt | 64 ++ .../jellyfin/models/FindroidSeasonDto.kt | 42 + .../jdtech/jellyfin/models/FindroidShow.kt | 83 ++ .../jdtech/jellyfin/models/FindroidShowDto.kt | 38 + .../jdtech/jellyfin/models/FindroidSource.kt | 62 ++ .../jellyfin/models/FindroidSourceDto.kt | 28 + .../jdtech/jellyfin/models/FindroidSources.kt | 6 + .../jellyfin/models/FindroidUserDataDto.kt | 27 + .../java/dev/jdtech/jellyfin/models/Intro.kt | 9 + .../dev/jdtech/jellyfin/models/IntroDto.kt | 25 + .../java/dev/jdtech/jellyfin/models/Server.kt | 0 .../jdtech/jellyfin/models/ServerAddress.kt | 0 .../jellyfin/models/ServerWithAddresses.kt | 0 .../models/ServerWithAddressesAndUsers.kt | 0 .../jdtech/jellyfin/models/ServerWithUsers.kt | 0 .../dev/jdtech/jellyfin/models/StorageItem.kt | 12 + .../jellyfin/models/TrickPlayManifest.kt | 7 + .../jellyfin/models/TrickPlayManifestDto.kt | 21 + .../java/dev/jdtech/jellyfin/models/User.kt | 0 .../jellyfin/repository/ItemsPagingSource.kt | 26 +- .../jellyfin/repository/JellyfinRepository.kt | 56 +- .../repository/JellyfinRepositoryImpl.kt | 446 +++++++--- .../JellyfinRepositoryOfflineImpl.kt | 290 +++++++ gradle/libs.versions.toml | 5 + .../jdtech/jellyfin/database/Converters.kt | 16 - .../jellyfin/database/DownloadDatabase.kt | 16 - .../jellyfin/database/DownloadDatabaseDao.kt | 31 - .../jdtech/jellyfin/models/DownloadItem.kt | 26 - .../dev/jdtech/jellyfin/models/PlayerItem.kt | 1 - .../jellyfin/utils/DownloadUtilities.kt | 279 ------ .../dialogs/VideoVersionDialogFragment.kt | 41 +- .../java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt | 19 +- .../viewmodels/PlayerActivityViewModel.kt | 65 +- .../jellyfin/viewmodels/PlayerViewModel.kt | 200 ++--- .../dev/jdtech/jellyfin/AppPreferences.kt | 16 + .../java/dev/jdtech/jellyfin/Constants.kt | 1 + 143 files changed, 7395 insertions(+), 3608 deletions(-) delete mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt delete mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadSeriesListAdapter.kt delete mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt delete mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt create mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/dialogs/StorageSelectionDialog.kt delete mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt delete mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadSeriesFragment.kt create mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadsFragment.kt delete mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt create mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt create mode 100644 app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt rename app/phone/src/main/res/layout-w600dp/{fragment_media_info.xml => fragment_show.xml} (75%) create mode 100644 app/phone/src/main/res/layout/card_offline.xml delete mode 100644 app/phone/src/main/res/layout/fragment_download.xml delete mode 100644 app/phone/src/main/res/layout/fragment_download_series.xml create mode 100644 app/phone/src/main/res/layout/fragment_downloads.xml create mode 100644 app/phone/src/main/res/layout/fragment_movie.xml rename app/phone/src/main/res/layout/{fragment_media_info.xml => fragment_show.xml} (74%) create mode 100644 app/phone/src/main/res/layout/item_actions.xml create mode 100644 app/phone/src/main/res/layout/preparing_download_dialog.xml delete mode 100644 core/src/main/java/dev/jdtech/jellyfin/database/ServerDatabase.kt delete mode 100644 core/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/di/DownloaderModule.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/utils/DownloadReceiver.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/utils/Downloader.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/utils/DownloaderImpl.kt delete mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadSeriesViewModel.kt delete mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadsViewModel.kt delete mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/MovieViewModel.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/ShowViewModel.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/StorageViewModel.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/work/SyncWorker.kt create mode 100644 core/src/main/res/drawable/ic_database.xml create mode 100644 core/src/main/res/drawable/ic_server_off.xml create mode 100644 core/src/main/res/menu/bottom_nav_menu_offline.xml create mode 100644 data/schemas/dev.jdtech.jellyfin.database.ServerDatabase/2.json create mode 100644 data/schemas/dev.jdtech.jellyfin.database.ServerDatabase/3.json create mode 100644 data/src/main/java/dev/jdtech/jellyfin/database/Converters.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/database/ServerDatabase.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt rename {core => data}/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt (88%) create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidBoxSet.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidCollection.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidEpisode.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidEpisodeDto.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidItem.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidMediaStream.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidMediaStreamDto.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidMovie.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidMovieDto.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidSeason.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidSeasonDto.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidShow.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidShowDto.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidSource.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidSourceDto.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidSources.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidUserDataDto.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/IntroDto.kt rename {core => data}/src/main/java/dev/jdtech/jellyfin/models/Server.kt (100%) rename {core => data}/src/main/java/dev/jdtech/jellyfin/models/ServerAddress.kt (100%) rename {core => data}/src/main/java/dev/jdtech/jellyfin/models/ServerWithAddresses.kt (100%) rename {core => data}/src/main/java/dev/jdtech/jellyfin/models/ServerWithAddressesAndUsers.kt (100%) rename {core => data}/src/main/java/dev/jdtech/jellyfin/models/ServerWithUsers.kt (100%) create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/StorageItem.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/TrickPlayManifestDto.kt rename {core => data}/src/main/java/dev/jdtech/jellyfin/models/User.kt (100%) create mode 100644 data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryOfflineImpl.kt delete mode 100644 player/core/src/main/java/dev/jdtech/jellyfin/database/Converters.kt delete mode 100644 player/core/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabase.kt delete mode 100644 player/core/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt delete mode 100644 player/core/src/main/java/dev/jdtech/jellyfin/models/DownloadItem.kt delete mode 100644 player/core/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt diff --git a/app/phone/build.gradle.kts b/app/phone/build.gradle.kts index ea85a0a1..d61a881f 100644 --- a/app/phone/build.gradle.kts +++ b/app/phone/build.gradle.kts @@ -82,6 +82,7 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) + implementation(libs.androidx.hilt.work) implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.media3.exoplayer) @@ -95,6 +96,7 @@ dependencies { implementation(libs.androidx.recyclerview.selection) implementation(libs.androidx.room.ktx) implementation(libs.androidx.swiperefreshlayout) + implementation(libs.androidx.work) implementation(libs.glide) implementation(libs.hilt.android) kapt(libs.hilt.compiler) diff --git a/app/phone/src/main/AndroidManifest.xml b/app/phone/src/main/AndroidManifest.xml index f1d16eb1..b7e06395 100644 --- a/app/phone/src/main/AndroidManifest.xml +++ b/app/phone/src/main/AndroidManifest.xml @@ -1,10 +1,14 @@ - + + - + + + + + + + + + \ No newline at end of file diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt index 2d7a78dc..de37337a 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt @@ -2,16 +2,26 @@ package dev.jdtech.jellyfin import android.app.Application import androidx.appcompat.app.AppCompatDelegate +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import com.google.android.material.color.DynamicColors import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject import timber.log.Timber @HiltAndroidApp -class BaseApplication : Application() { +class BaseApplication : Application(), Configuration.Provider { @Inject lateinit var appPreferences: AppPreferences + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override fun getWorkManagerConfiguration() = + Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + override fun onCreate() { super.onCreate() diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt index 71c5014e..2ba62bcc 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt @@ -12,6 +12,9 @@ import dev.jdtech.jellyfin.adapters.ServerGridAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.core.R as CoreR +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie import dev.jdtech.jellyfin.models.Server import dev.jdtech.jellyfin.models.User import java.util.UUID @@ -27,7 +30,7 @@ fun bindServers(recyclerView: RecyclerView, data: List?) { } @BindingAdapter("items") -fun bindItems(recyclerView: RecyclerView, data: List?) { +fun bindItems(recyclerView: RecyclerView, data: List?) { val adapter = recyclerView.adapter as ViewItemListAdapter adapter.submitList(data) } @@ -42,8 +45,21 @@ fun bindItemImage(imageView: ImageView, item: BaseItemDto) { .posterDescription(item.name) } +@BindingAdapter("itemImage") +fun bindItemImage(imageView: ImageView, item: FindroidItem) { + val itemId = when (item) { + is FindroidEpisode -> item.seriesId +// is JellyfinSeasonItem && item.imageTags.isNullOrEmpty() -> item.seriesId + else -> item.id + } + + imageView + .loadImage("/items/$itemId/Images/${ImageType.PRIMARY}") + .posterDescription(item.name) +} + @BindingAdapter("itemBackdropImage") -fun bindItemBackdropImage(imageView: ImageView, item: BaseItemDto?) { +fun bindItemBackdropImage(imageView: ImageView, item: FindroidItem?) { if (item == null) return imageView @@ -64,41 +80,21 @@ fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) { } @BindingAdapter("homeEpisodes") -fun bindHomeEpisodes(recyclerView: RecyclerView, data: List?) { +fun bindHomeEpisodes(recyclerView: RecyclerView, data: List?) { val adapter = recyclerView.adapter as HomeEpisodeListAdapter adapter.submitList(data) } -@BindingAdapter("baseItemImage") -fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) { - if (episode == null) return - - var imageItemId = episode.id - var imageType = ImageType.PRIMARY - - if (!episode.imageTags.isNullOrEmpty()) { // TODO: Downloadmetadata currently does not store imagetags, so it always uses the backdrop - when (episode.type) { - BaseItemKind.MOVIE -> { - if (!episode.backdropImageTags.isNullOrEmpty()) { - imageType = ImageType.BACKDROP - } - } - else -> { - if (!episode.imageTags!!.keys.contains(ImageType.PRIMARY)) { - imageType = ImageType.BACKDROP - } - } - } - } else { - if (episode.type == BaseItemKind.EPISODE) { - imageItemId = episode.seriesId!! - imageType = ImageType.BACKDROP - } +@BindingAdapter("cardItemImage") +fun bindCardItemImage(imageView: ImageView, item: FindroidItem) { + val imageType = when (item) { + is FindroidMovie -> ImageType.BACKDROP + else -> ImageType.PRIMARY } imageView - .loadImage("/items/$imageItemId/Images/$imageType") - .posterDescription(episode.name) + .loadImage("/items/${item.id}/Images/$imageType") + .posterDescription(item.name) } @BindingAdapter("seasonPoster") diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt index 46946c0c..603e03b5 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt @@ -1,9 +1,11 @@ package dev.jdtech.jellyfin import android.os.Bundle +import android.os.Environment import android.view.View import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavGraph import androidx.navigation.fragment.NavHostFragment @@ -11,14 +13,20 @@ import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.NavigationUiSaveStateControl import androidx.navigation.ui.setupActionBarWithNavController +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager import com.google.android.material.navigation.NavigationBarView import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.database.ServerDatabaseDao import dev.jdtech.jellyfin.databinding.ActivityMainBinding -import dev.jdtech.jellyfin.utils.loadDownloadLocation import dev.jdtech.jellyfin.viewmodels.MainViewModel +import dev.jdtech.jellyfin.work.SyncWorker import javax.inject.Inject +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -39,6 +47,26 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + print("OnCreate LOOOOOL") + + val syncWorkRequest = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType( + NetworkType.CONNECTED + ) + .build() + ) + .build() + + val workManager = WorkManager.getInstance(applicationContext) + + workManager.beginUniqueWork("syncUserData", ExistingWorkPolicy.KEEP, syncWorkRequest).enqueue() + + if (!appPreferences.downloadsMigrated) { + cleanUpOldDownloads() + } + if (appPreferences.amoledTheme) { setTheme(CoreR.style.Theme_FindroidAMOLED) } @@ -62,6 +90,11 @@ class MainActivity : AppCompatActivity() { val navView: NavigationBarView = binding.navView as NavigationBarView + if (appPreferences.offlineMode) { + navView.menu.clear() + navView.inflateMenu(CoreR.menu.bottom_nav_menu_offline) + } + setSupportActionBar(binding.mainToolbar) // Passing each menu ID as a set of Ids because each @@ -71,7 +104,7 @@ class MainActivity : AppCompatActivity() { R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment, - R.id.downloadFragment + R.id.downloadsFragment, ) ) @@ -88,8 +121,6 @@ class MainActivity : AppCompatActivity() { if (destination.id == com.mikepenz.aboutlibraries.R.id.about_libraries_dest) binding.mainToolbar.title = getString(CoreR.string.app_info) } - - loadDownloadLocation(applicationContext) } override fun onSupportNavigateUp(): Boolean { @@ -119,4 +150,25 @@ class MainActivity : AppCompatActivity() { } } } + + /** + * Temp to remove old downloads, will be removed in a future version + */ + private fun cleanUpOldDownloads() { + lifecycleScope.launch { + val oldDir = applicationContext.getExternalFilesDir(Environment.DIRECTORY_MOVIES) + if (oldDir == null) { + appPreferences.downloadsMigrated = true + return@launch + } + + try { + for (file in oldDir.listFiles()!!) { + file.delete() + } + } catch (_: Exception) {} + + appPreferences.downloadsMigrated = true + } + } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/CollectionListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/CollectionListAdapter.kt index 61c4cd52..b9553773 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/CollectionListAdapter.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/CollectionListAdapter.kt @@ -6,25 +6,25 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import dev.jdtech.jellyfin.databinding.CollectionItemBinding -import org.jellyfin.sdk.model.api.BaseItemDto +import dev.jdtech.jellyfin.models.FindroidCollection class CollectionListAdapter( private val onClickListener: OnClickListener -) : ListAdapter(DiffCallback) { +) : ListAdapter(DiffCallback) { class CollectionViewHolder(private var binding: CollectionItemBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(collection: BaseItemDto) { + fun bind(collection: FindroidCollection) { binding.collection = collection binding.executePendingBindings() } } - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FindroidCollection, newItem: FindroidCollection): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { + override fun areContentsTheSame(oldItem: FindroidCollection, newItem: FindroidCollection): Boolean { return oldItem == newItem } } @@ -47,7 +47,7 @@ class CollectionListAdapter( holder.bind(collection) } - class OnClickListener(val clickListener: (collection: BaseItemDto) -> Unit) { - fun onClick(collection: BaseItemDto) = clickListener(collection) + class OnClickListener(val clickListener: (collection: FindroidCollection) -> Unit) { + fun onClick(collection: FindroidCollection) = clickListener(collection) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt deleted file mode 100644 index 793c492b..00000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt +++ /dev/null @@ -1,116 +0,0 @@ -package dev.jdtech.jellyfin.adapters - -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import dev.jdtech.jellyfin.databinding.EpisodeItemBinding -import dev.jdtech.jellyfin.databinding.SeasonHeaderBinding -import dev.jdtech.jellyfin.models.DownloadEpisodeItem -import dev.jdtech.jellyfin.models.DownloadSeriesMetadata -import dev.jdtech.jellyfin.models.PlayerItem -import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDto - -private const val ITEM_VIEW_TYPE_HEADER = 0 -private const val ITEM_VIEW_TYPE_EPISODE = 1 - -class DownloadEpisodeListAdapter( - private val onClickListener: OnClickListener, - private val downloadSeriesMetadata: DownloadSeriesMetadata -) : - ListAdapter(DiffCallback) { - - class HeaderViewHolder(private var binding: SeasonHeaderBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind( - metadata: DownloadSeriesMetadata - ) { - binding.seasonName.text = metadata.name - binding.seriesId = metadata.itemId - binding.seasonId = metadata.itemId - binding.executePendingBindings() - } - } - - class EpisodeViewHolder(private var binding: EpisodeItemBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(episode: BaseItemDto) { - binding.episode = episode - if (episode.userData?.playedPercentage != null) { - binding.progressBar.layoutParams.width = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - (episode.userData?.playedPercentage?.times(.84))!!.toFloat(), - binding.progressBar.context.resources.displayMetrics - ).toInt() - binding.progressBar.visibility = View.VISIBLE - } else { - binding.progressBar.visibility = View.GONE - } - binding.executePendingBindings() - } - } - - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: DownloadEpisodeItem, newItem: DownloadEpisodeItem): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: DownloadEpisodeItem, newItem: DownloadEpisodeItem): Boolean { - return oldItem == newItem - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - ITEM_VIEW_TYPE_HEADER -> { - HeaderViewHolder( - SeasonHeaderBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - ITEM_VIEW_TYPE_EPISODE -> { - EpisodeViewHolder( - EpisodeItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - else -> throw ClassCastException("Unknown viewType $viewType") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder.itemViewType) { - ITEM_VIEW_TYPE_HEADER -> { - (holder as HeaderViewHolder).bind(downloadSeriesMetadata) - } - ITEM_VIEW_TYPE_EPISODE -> { - val item = getItem(position) as DownloadEpisodeItem.Episode - holder.itemView.setOnClickListener { - onClickListener.onClick(item.episode) - } - (holder as EpisodeViewHolder).bind(downloadMetadataToBaseItemDto(item.episode.item!!)) - } - } - } - - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is DownloadEpisodeItem.Header -> ITEM_VIEW_TYPE_HEADER - is DownloadEpisodeItem.Episode -> ITEM_VIEW_TYPE_EPISODE - } - } - - class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) { - fun onClick(item: PlayerItem) = clickListener(item) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadSeriesListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadSeriesListAdapter.kt deleted file mode 100644 index 650bd285..00000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadSeriesListAdapter.kt +++ /dev/null @@ -1,71 +0,0 @@ -package dev.jdtech.jellyfin.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import dev.jdtech.jellyfin.core.R as CoreR -import dev.jdtech.jellyfin.databinding.BaseItemBinding -import dev.jdtech.jellyfin.models.DownloadSeriesMetadata -import dev.jdtech.jellyfin.utils.downloadSeriesMetadataToBaseItemDto - -class DownloadSeriesListAdapter( - private val onClickListener: OnClickListener, - private val fixedWidth: Boolean = false, -) : ListAdapter(DiffCallback) { - - class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) : - RecyclerView.ViewHolder(binding.root) { - fun bind(item: DownloadSeriesMetadata, fixedWidth: Boolean) { - binding.item = downloadSeriesMetadataToBaseItemDto(item) - binding.itemName.text = item.name - binding.itemCount.text = item.episodes.size.toString() - if (fixedWidth) { - binding.itemLayout.layoutParams.width = - parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt() - (binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0 - } - binding.executePendingBindings() - } - } - - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: DownloadSeriesMetadata, - newItem: DownloadSeriesMetadata - ): Boolean { - return oldItem.itemId == newItem.itemId - } - - override fun areContentsTheSame( - oldItem: DownloadSeriesMetadata, - newItem: DownloadSeriesMetadata - ): Boolean { - return oldItem == newItem - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { - return ItemViewHolder( - BaseItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ), - parent - ) - } - - override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { - val item = getItem(position) - holder.itemView.setOnClickListener { - onClickListener.onClick(item) - } - holder.bind(item, fixedWidth) - } - - class OnClickListener(val clickListener: (item: DownloadSeriesMetadata) -> Unit) { - fun onClick(item: DownloadSeriesMetadata) = clickListener(item) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt deleted file mode 100644 index d57795f3..00000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.jdtech.jellyfin.adapters - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import dev.jdtech.jellyfin.core.R as CoreR -import dev.jdtech.jellyfin.databinding.BaseItemBinding -import dev.jdtech.jellyfin.models.PlayerItem -import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto - -class DownloadViewItemListAdapter( - private val onClickListener: OnClickListener, - private val fixedWidth: Boolean = false, -) : ListAdapter(DiffCallback) { - - class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) : - RecyclerView.ViewHolder(binding.root) { - fun bind(item: PlayerItem, fixedWidth: Boolean) { - val metadata = item.item!! - binding.item = downloadMetadataToBaseItemDto(metadata) - binding.itemName.text = item.name - binding.itemCount.visibility = View.GONE - if (fixedWidth) { - binding.itemLayout.layoutParams.width = parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt() - (binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0 - } - binding.executePendingBindings() - } - } - - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean { - return oldItem.itemId == newItem.itemId - } - - override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean { - return oldItem == newItem - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { - return ItemViewHolder( - BaseItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ), - parent - ) - } - - override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { - val item = getItem(position) - holder.itemView.setOnClickListener { - onClickListener.onClick(item) - } - holder.bind(item, fixedWidth) - } - - class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) { - fun onClick(item: PlayerItem) = clickListener(item) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt deleted file mode 100644 index 9f9f819b..00000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.jdtech.jellyfin.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import dev.jdtech.jellyfin.databinding.DownloadSectionBinding -import dev.jdtech.jellyfin.models.DownloadSection - -class DownloadsListAdapter( - private val onClickListener: DownloadViewItemListAdapter.OnClickListener, - private val onSeriesClickListener: DownloadSeriesListAdapter.OnClickListener -) : ListAdapter(DiffCallback) { - class SectionViewHolder(private var binding: DownloadSectionBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind( - section: DownloadSection, - onClickListener: DownloadViewItemListAdapter.OnClickListener, - onSeriesClickListener: DownloadSeriesListAdapter.OnClickListener - ) { - binding.section = section - when (section.name) { - "Movies" -> { - binding.itemsRecyclerView.adapter = DownloadViewItemListAdapter(onClickListener, fixedWidth = true) - (binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items) - } - "Shows" -> { - binding.itemsRecyclerView.adapter = DownloadSeriesListAdapter(onSeriesClickListener, fixedWidth = true) - (binding.itemsRecyclerView.adapter as DownloadSeriesListAdapter).submitList(section.series) - } - } - binding.executePendingBindings() - } - } - - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: DownloadSection, newItem: DownloadSection): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame( - oldItem: DownloadSection, - newItem: DownloadSection - ): Boolean { - return oldItem == newItem - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder { - return SectionViewHolder( - DownloadSectionBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindViewHolder(holder: SectionViewHolder, position: Int) { - val collection = getItem(position) - holder.bind(collection, onClickListener, onSeriesClickListener) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/EpisodeListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/EpisodeListAdapter.kt index b425b552..33ade90c 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/EpisodeListAdapter.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/EpisodeListAdapter.kt @@ -4,57 +4,52 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import dev.jdtech.jellyfin.databinding.EpisodeItemBinding import dev.jdtech.jellyfin.databinding.SeasonHeaderBinding import dev.jdtech.jellyfin.models.EpisodeItem -import java.util.UUID -import org.jellyfin.sdk.model.api.BaseItemDto +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.isDownloaded private const val ITEM_VIEW_TYPE_HEADER = 0 private const val ITEM_VIEW_TYPE_EPISODE = 1 class EpisodeListAdapter( private val onClickListener: OnClickListener, - private val seriesId: UUID, - private val seriesName: String?, - private val seasonId: UUID, - private val seasonName: String? ) : ListAdapter(DiffCallback) { class HeaderViewHolder(private var binding: SeasonHeaderBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind( - seriesId: UUID, - seriesName: String?, - seasonId: UUID, - seasonName: String? - ) { - binding.seriesId = seriesId - binding.seasonId = seasonId - binding.seasonName.text = seasonName - binding.seriesName.text = seriesName + fun bind(header: EpisodeItem.Header) { + binding.seriesId = header.seriesId + binding.seasonId = header.seasonId + binding.seasonName.text = header.seasonName + binding.seriesName.text = header.seriesName binding.executePendingBindings() } } class EpisodeViewHolder(private var binding: EpisodeItemBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(episode: BaseItemDto) { + fun bind(episode: FindroidEpisode) { binding.episode = episode - if (episode.userData?.playedPercentage != null) { + if (episode.playbackPositionTicks > 0) { binding.progressBar.layoutParams.width = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, - (episode.userData?.playedPercentage?.times(.84))!!.toFloat(), + (episode.playbackPositionTicks.div(episode.runtimeTicks.toFloat()).times(84)), binding.progressBar.context.resources.displayMetrics ).toInt() binding.progressBar.visibility = View.VISIBLE } else { binding.progressBar.visibility = View.GONE } + + binding.downloadedIcon.isVisible = episode.isDownloaded() + binding.executePendingBindings() } } @@ -96,7 +91,8 @@ class EpisodeListAdapter( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder.itemViewType) { ITEM_VIEW_TYPE_HEADER -> { - (holder as HeaderViewHolder).bind(seriesId, seriesName, seasonId, seasonName) + val item = getItem(position) as EpisodeItem.Header + (holder as HeaderViewHolder).bind(item) } ITEM_VIEW_TYPE_EPISODE -> { val item = getItem(position) as EpisodeItem.Episode @@ -115,7 +111,7 @@ class EpisodeListAdapter( } } - class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) { - fun onClick(item: BaseItemDto) = clickListener(item) + class OnClickListener(val clickListener: (item: FindroidEpisode) -> Unit) { + fun onClick(item: FindroidEpisode) = clickListener(item) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/HomeEpisodeListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/HomeEpisodeListAdapter.kt index 42a83f01..82be6498 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/HomeEpisodeListAdapter.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/HomeEpisodeListAdapter.kt @@ -4,44 +4,57 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.isDownloaded -class HomeEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter(DiffCallback) { - class EpisodeViewHolder(private var binding: HomeEpisodeItemBinding) : +class HomeEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter(DiffCallback) { + class EpisodeViewHolder( + private var binding: HomeEpisodeItemBinding, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(binding.root) { - fun bind(episode: BaseItemDto) { - binding.episode = episode - if (episode.userData?.playedPercentage != null) { + fun bind(item: FindroidItem) { + binding.item = item + if (item.playbackPositionTicks > 0) { binding.progressBar.layoutParams.width = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, - (episode.userData?.playedPercentage?.times(2.24))!!.toFloat(), binding.progressBar.context.resources.displayMetrics + (item.playbackPositionTicks.div(item.runtimeTicks.toFloat()).times(224)), binding.progressBar.context.resources.displayMetrics ).toInt() binding.progressBar.visibility = View.VISIBLE } - if (episode.type == BaseItemKind.MOVIE) { - binding.primaryName.text = episode.name - binding.secondaryName.visibility = View.GONE - } else if (episode.type == BaseItemKind.EPISODE) { - binding.primaryName.text = episode.seriesName + binding.downloadedIcon.isVisible = item.isDownloaded() + + when (item) { + is FindroidMovie -> { + binding.primaryName.text = item.name + binding.secondaryName.visibility = View.GONE + } + is FindroidEpisode -> { + binding.primaryName.text = item.seriesName + binding.secondaryName.text = parent.resources.getString(CoreR.string.episode_name_extended, item.parentIndexNumber, item.indexNumber, item.name) + } } binding.executePendingBindings() } } - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { - return oldItem == newItem + override fun areContentsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean { + return oldItem.name == newItem.name } } @@ -51,7 +64,8 @@ class HomeEpisodeListAdapter(private val onClickListener: OnClickListener) : Lis LayoutInflater.from(parent.context), parent, false - ) + ), + parent ) } @@ -63,7 +77,7 @@ class HomeEpisodeListAdapter(private val onClickListener: OnClickListener) : Lis holder.bind(item) } - class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) { - fun onClick(item: BaseItemDto) = clickListener(item) + class OnClickListener(val clickListener: (item: FindroidItem) -> Unit) { + fun onClick(item: FindroidItem) = clickListener(item) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemListAdapter.kt index b2ff7cbd..afa1d4a1 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemListAdapter.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemListAdapter.kt @@ -3,42 +3,47 @@ package dev.jdtech.jellyfin.adapters import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.databinding.BaseItemBinding -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.isDownloaded class ViewItemListAdapter( private val onClickListener: OnClickListener, private val fixedWidth: Boolean = false, -) : ListAdapter(DiffCallback) { +) : ListAdapter(DiffCallback) { class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: BaseItemDto, fixedWidth: Boolean) { + fun bind(item: FindroidItem, fixedWidth: Boolean) { binding.item = item - binding.itemName.text = if (item.type == BaseItemKind.EPISODE) item.seriesName else item.name + binding.itemName.text = if (item is FindroidEpisode) item.seriesName else item.name binding.itemCount.visibility = - if (item.userData?.unplayedItemCount != null && item.userData?.unplayedItemCount!! > 0) View.VISIBLE else View.GONE + if (item.unplayedItemCount != null && item.unplayedItemCount!! > 0) View.VISIBLE else View.GONE if (fixedWidth) { binding.itemLayout.layoutParams.width = parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt() (binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0 } + + binding.downloadedIcon.isVisible = item.isDownloaded() + binding.executePendingBindings() } } - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { - return oldItem == newItem + override fun areContentsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean { + return oldItem.name == newItem.name } } @@ -61,7 +66,7 @@ class ViewItemListAdapter( holder.bind(item, fixedWidth) } - class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) { - fun onClick(item: BaseItemDto) = clickListener(item) + class OnClickListener(val clickListener: (item: FindroidItem) -> Unit) { + fun onClick(item: FindroidItem) = clickListener(item) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemPagingAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemPagingAdapter.kt index 37e9705d..b9a374db 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemPagingAdapter.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewItemPagingAdapter.kt @@ -3,43 +3,48 @@ package dev.jdtech.jellyfin.adapters import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.databinding.BaseItemBinding -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.isDownloaded class ViewItemPagingAdapter( private val onClickListener: OnClickListener, private val fixedWidth: Boolean = false, -) : PagingDataAdapter(DiffCallback) { +) : PagingDataAdapter(DiffCallback) { class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: BaseItemDto, fixedWidth: Boolean) { + fun bind(item: FindroidItem, fixedWidth: Boolean) { binding.item = item binding.itemName.text = - if (item.type == BaseItemKind.EPISODE) item.seriesName else item.name + if (item is FindroidEpisode) item.seriesName else item.name binding.itemCount.visibility = - if (item.userData?.unplayedItemCount != null && item.userData?.unplayedItemCount!! > 0) View.VISIBLE else View.GONE + if (item.unplayedItemCount != null && item.unplayedItemCount!! > 0) View.VISIBLE else View.GONE if (fixedWidth) { binding.itemLayout.layoutParams.width = parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt() (binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0 } + + binding.downloadedIcon.isVisible = item.isDownloaded() + binding.executePendingBindings() } } - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean { - return oldItem == newItem + override fun areContentsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean { + return oldItem.name == newItem.name } } @@ -64,7 +69,7 @@ class ViewItemPagingAdapter( } } - class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) { - fun onClick(item: BaseItemDto) = clickListener(item) + class OnClickListener(val clickListener: (item: FindroidItem) -> Unit) { + fun onClick(item: FindroidItem) = clickListener(item) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt index 75787a5d..6eeff900 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt @@ -6,6 +6,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import dev.jdtech.jellyfin.core.R as CoreR +import dev.jdtech.jellyfin.databinding.CardOfflineBinding import dev.jdtech.jellyfin.databinding.NextUpSectionBinding import dev.jdtech.jellyfin.databinding.ViewItemBinding import dev.jdtech.jellyfin.models.HomeItem @@ -13,11 +14,13 @@ import dev.jdtech.jellyfin.models.View private const val ITEM_VIEW_TYPE_NEXT_UP = 0 private const val ITEM_VIEW_TYPE_VIEW = 1 +private const val ITEM_VIEW_TYPE_OFFLINE_CARD = 2 class ViewListAdapter( private val onClickListener: OnClickListener, private val onItemClickListener: ViewItemListAdapter.OnClickListener, - private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener + private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener, + private val onOnlineClickListener: OnClickListenerOfflineCard ) : ListAdapter(DiffCallback) { class ViewViewHolder(private var binding: ViewItemBinding) : @@ -43,11 +46,20 @@ class ViewListAdapter( RecyclerView.ViewHolder(binding.root) { fun bind(section: HomeItem.Section, onClickListener: HomeEpisodeListAdapter.OnClickListener) { binding.section = section.homeSection + binding.sectionName.text = section.homeSection.name.asString(binding.sectionName.context.resources) binding.itemsRecyclerView.adapter = HomeEpisodeListAdapter(onClickListener) binding.executePendingBindings() } } + class OfflineCardViewHolder(private var binding: CardOfflineBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(onClickListener: OnClickListenerOfflineCard) { + binding.onlineButton.setOnClickListener { + onClickListener.onClick() + } + } + } + companion object DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean { return oldItem.id == newItem.id @@ -75,6 +87,15 @@ class ViewListAdapter( false ) ) + ITEM_VIEW_TYPE_OFFLINE_CARD -> { + OfflineCardViewHolder( + CardOfflineBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } else -> throw ClassCastException("Unknown viewType $viewType") } } @@ -89,11 +110,15 @@ class ViewListAdapter( val view = getItem(position) as HomeItem.ViewItem (holder as ViewViewHolder).bind(view, onClickListener, onItemClickListener) } + ITEM_VIEW_TYPE_OFFLINE_CARD -> { + (holder as OfflineCardViewHolder).bind(onOnlineClickListener) + } } } override fun getItemViewType(position: Int): Int { return when (getItem(position)) { + is HomeItem.OfflineCard -> ITEM_VIEW_TYPE_OFFLINE_CARD is HomeItem.Libraries -> -1 is HomeItem.Section -> ITEM_VIEW_TYPE_NEXT_UP is HomeItem.ViewItem -> ITEM_VIEW_TYPE_VIEW @@ -103,4 +128,8 @@ class ViewListAdapter( class OnClickListener(val clickListener: (view: View) -> Unit) { fun onClick(view: View) = clickListener(view) } + + class OnClickListenerOfflineCard(val clickListener: () -> Unit) { + fun onClick() = clickListener() + } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/dialogs/StorageSelectionDialog.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/dialogs/StorageSelectionDialog.kt new file mode 100644 index 00000000..61e646aa --- /dev/null +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/dialogs/StorageSelectionDialog.kt @@ -0,0 +1,29 @@ +package dev.jdtech.jellyfin.dialogs + +import android.content.Context +import android.os.Environment +import android.os.StatFs +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.jdtech.jellyfin.core.R as CoreR + +fun getStorageSelectionDialog( + context: Context, + onItemSelected: (which: Int) -> Unit, + onCancel: () -> Unit, +): AlertDialog { + val locations = context.getExternalFilesDirs(null).mapNotNull { + val locationStringRes = if (Environment.isExternalStorageRemovable(it)) CoreR.string.external else CoreR.string.internal + val stat = StatFs(it.path) + context.getString(CoreR.string.storage_name, context.getString(locationStringRes), stat.availableBytes.div(1000000)) + }.toTypedArray() + val dialog = MaterialAlertDialogBuilder(context) + .setTitle(CoreR.string.select_storage_location) + .setItems(locations) { _, which -> + onItemSelected(which) + } + .setOnCancelListener() { + onCancel() + }.create() + return dialog +} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/CollectionFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/CollectionFragment.kt index 6b8a1d14..e4a859a8 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/CollectionFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/CollectionFragment.kt @@ -18,10 +18,13 @@ import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.CollectionViewModel import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber @AndroidEntryPoint @@ -41,10 +44,10 @@ class CollectionFragment : Fragment() { binding.favoritesRecyclerView.adapter = FavoritesListAdapter( ViewItemListAdapter.OnClickListener { item -> - navigateToMediaInfoFragment(item) + navigateToMediaItem(item) }, HomeEpisodeListAdapter.OnClickListener { item -> - navigateToEpisodeBottomSheetFragment(item) + navigateToMediaItem(item) } ) @@ -103,21 +106,31 @@ class CollectionFragment : Fragment() { checkIfLoginRequired(uiState.error.message) } - private fun navigateToMediaInfoFragment(item: BaseItemDto) { - findNavController().navigate( - CollectionFragmentDirections.actionCollectionFragmentToMediaInfoFragment( - item.id, - item.name, - item.type - ) - ) - } - - private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { - findNavController().navigate( - CollectionFragmentDirections.actionCollectionFragmentToEpisodeBottomSheetFragment( - episode.id - ) - ) + private fun navigateToMediaItem(item: FindroidItem) { + when (item) { + is FindroidMovie -> { + findNavController().navigate( + CollectionFragmentDirections.actionCollectionFragmentToMovieFragment( + item.id, + item.name + ) + ) + } + is FindroidShow -> { + findNavController().navigate( + CollectionFragmentDirections.actionCollectionFragmentToShowFragment( + item.id, + item.name + ) + ) + } + is FindroidEpisode -> { + findNavController().navigate( + CollectionFragmentDirections.actionCollectionFragmentToEpisodeBottomSheetFragment( + item.id + ) + ) + } + } } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt deleted file mode 100644 index 2f286786..00000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt +++ /dev/null @@ -1,117 +0,0 @@ -package dev.jdtech.jellyfin.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import dagger.hilt.android.AndroidEntryPoint -import dev.jdtech.jellyfin.adapters.DownloadSeriesListAdapter -import dev.jdtech.jellyfin.adapters.DownloadViewItemListAdapter -import dev.jdtech.jellyfin.adapters.DownloadsListAdapter -import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding -import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment -import dev.jdtech.jellyfin.models.DownloadSeriesMetadata -import dev.jdtech.jellyfin.models.PlayerItem -import dev.jdtech.jellyfin.utils.checkIfLoginRequired -import dev.jdtech.jellyfin.viewmodels.DownloadViewModel -import java.util.UUID -import kotlinx.coroutines.launch -import timber.log.Timber - -@AndroidEntryPoint -class DownloadFragment : Fragment() { - - private lateinit var binding: FragmentDownloadBinding - private val viewModel: DownloadViewModel by viewModels() - - private lateinit var errorDialog: ErrorDialogFragment - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentDownloadBinding.inflate(inflater, container, false) - - binding.downloadsRecyclerView.adapter = - DownloadsListAdapter( - DownloadViewItemListAdapter.OnClickListener { item -> - navigateToMediaInfoFragment(item) - }, - DownloadSeriesListAdapter.OnClickListener { item -> - navigateToDownloadSeriesFragment(item) - } - ) - - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { uiState -> - Timber.d("$uiState") - when (uiState) { - is DownloadViewModel.UiState.Normal -> bindUiStateNormal(uiState) - is DownloadViewModel.UiState.Loading -> bindUiStateLoading() - is DownloadViewModel.UiState.Error -> bindUiStateError(uiState) - } - } - } - } - - binding.errorLayout.errorRetryButton.setOnClickListener { - viewModel.loadData() - } - - binding.errorLayout.errorDetailsButton.setOnClickListener { - errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) - } - - return binding.root - } - - private fun bindUiStateNormal(uiState: DownloadViewModel.UiState.Normal) { - uiState.apply { - binding.noDownloadsText.isVisible = downloadSections.isEmpty() - - val adapter = binding.downloadsRecyclerView.adapter as DownloadsListAdapter - adapter.submitList(downloadSections) - } - binding.loadingIndicator.isVisible = false - binding.downloadsRecyclerView.isVisible = true - binding.errorLayout.errorPanel.isVisible = false - } - - private fun bindUiStateLoading() { - binding.loadingIndicator.isVisible = true - binding.errorLayout.errorPanel.isVisible = false - } - - private fun bindUiStateError(uiState: DownloadViewModel.UiState.Error) { - errorDialog = ErrorDialogFragment.newInstance(uiState.error) - binding.loadingIndicator.isVisible = false - binding.downloadsRecyclerView.isVisible = false - binding.errorLayout.errorPanel.isVisible = true - checkIfLoginRequired(uiState.error.message) - } - - private fun navigateToMediaInfoFragment(item: PlayerItem) { - findNavController().navigate( - DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment( - UUID.randomUUID(), item.name, item.item!!.type, item, isOffline = true - ) - ) - } - - private fun navigateToDownloadSeriesFragment(series: DownloadSeriesMetadata) { - findNavController().navigate( - DownloadFragmentDirections.actionDownloadFragmentToDownloadSeriesFragment( - seriesMetadata = series, seriesName = series.name - ) - ) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadSeriesFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadSeriesFragment.kt deleted file mode 100644 index 8d45c333..00000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadSeriesFragment.kt +++ /dev/null @@ -1,101 +0,0 @@ -package dev.jdtech.jellyfin.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import dagger.hilt.android.AndroidEntryPoint -import dev.jdtech.jellyfin.adapters.DownloadEpisodeListAdapter -import dev.jdtech.jellyfin.databinding.FragmentDownloadSeriesBinding -import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment -import dev.jdtech.jellyfin.models.PlayerItem -import dev.jdtech.jellyfin.viewmodels.DownloadSeriesViewModel -import java.util.UUID -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class DownloadSeriesFragment : Fragment() { - - private lateinit var binding: FragmentDownloadSeriesBinding - private val viewModel: DownloadSeriesViewModel by viewModels() - - private lateinit var errorDialog: ErrorDialogFragment - - private val args: DownloadSeriesFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentDownloadSeriesBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.viewModel = viewModel - - binding.episodesRecyclerView.adapter = - DownloadEpisodeListAdapter( - DownloadEpisodeListAdapter.OnClickListener { episode -> - navigateToEpisodeBottomSheetFragment(episode) - }, - args.seriesMetadata - ) - - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { uiState -> - when (uiState) { - is DownloadSeriesViewModel.UiState.Normal -> bindUiStateNormal(uiState) - is DownloadSeriesViewModel.UiState.Loading -> Unit - is DownloadSeriesViewModel.UiState.Error -> bindUiStateError(uiState) - } - } - } - } - - binding.errorLayout.errorRetryButton.setOnClickListener { - viewModel.loadEpisodes(args.seriesMetadata) - } - - binding.errorLayout.errorDetailsButton.setOnClickListener { - errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) - } - - viewModel.loadEpisodes(args.seriesMetadata) - } - - private fun bindUiStateNormal(uiState: DownloadSeriesViewModel.UiState.Normal) { - val adapter = binding.episodesRecyclerView.adapter as DownloadEpisodeListAdapter - adapter.submitList(uiState.downloadEpisodes) - binding.episodesRecyclerView.isVisible = true - binding.errorLayout.errorPanel.isVisible = false - } - - private fun bindUiStateError(uiState: DownloadSeriesViewModel.UiState.Error) { - errorDialog = ErrorDialogFragment.newInstance(uiState.error) - binding.episodesRecyclerView.isVisible = false - binding.errorLayout.errorPanel.isVisible = true - } - - private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) { - findNavController().navigate( - DownloadSeriesFragmentDirections.actionDownloadSeriesFragmentToEpisodeBottomSheetFragment( - UUID.randomUUID(), - episode, - isOffline = true - ) - ) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadsFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadsFragment.kt new file mode 100644 index 00000000..2ce4edec --- /dev/null +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/DownloadsFragment.kt @@ -0,0 +1,126 @@ +package dev.jdtech.jellyfin.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import dev.jdtech.jellyfin.AppPreferences +import dev.jdtech.jellyfin.R +import dev.jdtech.jellyfin.adapters.FavoritesListAdapter +import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter +import dev.jdtech.jellyfin.adapters.ViewItemListAdapter +import dev.jdtech.jellyfin.core.R as CoreR +import dev.jdtech.jellyfin.databinding.FragmentDownloadsBinding +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow +import dev.jdtech.jellyfin.utils.restart +import dev.jdtech.jellyfin.viewmodels.DownloadsViewModel +import javax.inject.Inject +import kotlinx.coroutines.launch +import timber.log.Timber + +@AndroidEntryPoint +class DownloadsFragment : Fragment() { + private lateinit var binding: FragmentDownloadsBinding + private val viewModel: DownloadsViewModel by viewModels() + + @Inject + lateinit var appPreferences: AppPreferences + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentDownloadsBinding.inflate(inflater, container, false) + + binding.downloadsRecyclerView.adapter = FavoritesListAdapter( + ViewItemListAdapter.OnClickListener { item -> + navigateToMediaItem(item) + }, + HomeEpisodeListAdapter.OnClickListener { item -> + navigateToMediaItem(item) + } + ) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.connectionError.collect { + Snackbar.make(binding.root, CoreR.string.no_server_connection, Snackbar.LENGTH_INDEFINITE) + .setAnchorView(requireActivity().findViewById(R.id.nav_view)) + .setAction(CoreR.string.offline_mode) { + appPreferences.offlineMode = true + activity?.restart() + } + .show() + } + } + launch { + viewModel.uiState.collect { uiState -> + Timber.d("$uiState") + when (uiState) { + is DownloadsViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is DownloadsViewModel.UiState.Loading -> bindUiStateLoading() + is DownloadsViewModel.UiState.Error -> Unit + } + } + } + } + } + + return binding.root + } + + override fun onResume() { + super.onResume() + + viewModel.loadData() + } + + private fun bindUiStateNormal(uiState: DownloadsViewModel.UiState.Normal) { + binding.loadingIndicator.isVisible = false + binding.downloadsRecyclerView.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + binding.noDownloadsText.isVisible = uiState.sections.isEmpty() + val adapter = binding.downloadsRecyclerView.adapter as FavoritesListAdapter + adapter.submitList(uiState.sections) + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun navigateToMediaItem(item: FindroidItem) { + when (item) { + is FindroidMovie -> { + findNavController().navigate( + DownloadsFragmentDirections.actionDownloadsFragmentToMovieFragment( + item.id, + item.name + ) + ) + } + is FindroidShow -> { + findNavController().navigate( + DownloadsFragmentDirections.actionDownloadsFragmentToShowFragment( + item.id, + item.name, + true + ) + ) + } + } + } +} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt index 2311a3fc..6de4f47a 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -1,10 +1,13 @@ package dev.jdtech.jellyfin.fragments +import android.R as AndroidR +import android.app.DownloadManager import android.os.Bundle import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels @@ -17,20 +20,30 @@ import com.google.android.material.R as MaterialR import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import dev.jdtech.jellyfin.bindBaseItemImage +import dev.jdtech.jellyfin.R +import dev.jdtech.jellyfin.bindCardItemImage import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.dialogs.getStorageSelectionDialog +import dev.jdtech.jellyfin.dialogs.getVideoVersionDialog +import dev.jdtech.jellyfin.models.FindroidSourceType import dev.jdtech.jellyfin.models.PlayerItem +import dev.jdtech.jellyfin.models.UiText +import dev.jdtech.jellyfin.models.isDownloaded +import dev.jdtech.jellyfin.models.isDownloading import dev.jdtech.jellyfin.utils.setTintColor import dev.jdtech.jellyfin.utils.setTintColorAttribute import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import java.text.DateFormat +import java.time.ZoneOffset +import java.util.Date import java.util.UUID import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.LocationType +import org.jellyfin.sdk.model.DateTime import timber.log.Timber @AndroidEntryPoint @@ -41,6 +54,8 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { private val viewModel: EpisodeBottomSheetViewModel by viewModels() private val playerViewModel: PlayerViewModel by viewModels() + private lateinit var downloadPreparingDialog: AlertDialog + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -48,31 +63,67 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { ): View { binding = EpisodeBottomSheetBinding.inflate(inflater, container, false) - binding.playButton.setOnClickListener { - binding.playButton.setImageResource(android.R.color.transparent) - binding.progressCircular.isVisible = true - if (viewModel.canRetry) { - binding.playButton.isEnabled = false - viewModel.download() - return@setOnClickListener - } - viewModel.item?.let { - if (!args.isOffline) { - playerViewModel.loadPlayerItems(it) - } else { - playerViewModel.loadOfflinePlayerItems(viewModel.playerItems[0]) - } - } + binding.itemActions.playButton.setOnClickListener { + binding.itemActions.playButton.setImageResource(AndroidR.color.transparent) + binding.itemActions.progressCircular.isVisible = true + playerViewModel.loadPlayerItems(viewModel.item) } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { uiState -> - Timber.d("$uiState") - when (uiState) { - is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState) - is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading() - is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState) + launch { + viewModel.uiState.collect { uiState -> + Timber.d("$uiState") + when (uiState) { + is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading() + is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } + + launch { + viewModel.downloadStatus.collect { (status, progress) -> + when (status) { + 10 -> { + downloadPreparingDialog.dismiss() + } + DownloadManager.STATUS_PENDING -> { + binding.itemActions.downloadButton.setImageResource(AndroidR.color.transparent) + binding.itemActions.progressDownload.isIndeterminate = true + binding.itemActions.progressDownload.isVisible = true + } + DownloadManager.STATUS_RUNNING -> { + binding.itemActions.downloadButton.setImageResource(AndroidR.color.transparent) + binding.itemActions.progressDownload.isVisible = true + if (progress < 5) { + binding.itemActions.progressDownload.isIndeterminate = true + } else { + binding.itemActions.progressDownload.isIndeterminate = false + binding.itemActions.progressDownload.setProgressCompat(progress, true) + } + } + DownloadManager.STATUS_SUCCESSFUL -> { + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_trash) + binding.itemActions.progressDownload.isVisible = false + } + else -> { + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + } + } + } + + launch { + viewModel.downloadError.collect { uiText -> + createErrorDialog(uiText) + } + } + + launch { + viewModel.navigateBack.collect { + if (it) findNavController().navigateUp() } } } @@ -85,59 +136,80 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } } - if (!args.isOffline) { - val episodeId: UUID = args.episodeId + binding.seriesName.setOnClickListener { + navigateToSeries(viewModel.item.seriesId, viewModel.item.seriesName) + } - binding.checkButton.setOnClickListener { - when (viewModel.played) { - true -> { - viewModel.markAsUnplayed(episodeId) - binding.checkButton.setTintColorAttribute(MaterialR.attr.colorOnSecondaryContainer, requireActivity().theme) - } - false -> { - viewModel.markAsPlayed(episodeId) - binding.checkButton.setTintColor(CoreR.color.red, requireActivity().theme) - } - } - } + binding.itemActions.checkButton.setOnClickListener { + val played = viewModel.togglePlayed() + bindCheckButtonState(played) + } - binding.favoriteButton.setOnClickListener { - when (viewModel.favorite) { - true -> { - viewModel.unmarkAsFavorite(episodeId) - binding.favoriteButton.setImageResource(CoreR.drawable.ic_heart) - binding.favoriteButton.setTintColorAttribute(MaterialR.attr.colorOnSecondaryContainer, requireActivity().theme) - } - false -> { - viewModel.markAsFavorite(episodeId) - binding.favoriteButton.setImageResource(CoreR.drawable.ic_heart_filled) - binding.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme) - } - } - } + binding.itemActions.favoriteButton.setOnClickListener { + val favorite = viewModel.toggleFavorite() + bindFavoriteButtonState(favorite) + } - binding.downloadButton.setOnClickListener { - binding.downloadButton.isEnabled = false - viewModel.download() - binding.downloadButton.setTintColor(CoreR.color.red, requireActivity().theme) - } - - viewModel.loadEpisode(episodeId) - } else { - val playerItem = args.playerItem!! - viewModel.loadEpisode(playerItem) - - binding.deleteButton.isVisible = true - - binding.deleteButton.setOnClickListener { + binding.itemActions.downloadButton.setOnClickListener { + if (viewModel.item.isDownloaded()) { viewModel.deleteEpisode() - dismiss() - findNavController().navigate(CoreR.id.downloadFragment) + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } else if (viewModel.item.isDownloading()) { + createCancelDialog() + } else { + binding.itemActions.downloadButton.setImageResource(android.R.color.transparent) + binding.itemActions.progressDownload.isIndeterminate = true + binding.itemActions.progressDownload.isVisible = true + if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) { + val storageDialog = getStorageSelectionDialog( + requireContext(), + onItemSelected = { storageIndex -> + if (viewModel.item.sources.size > 1) { + val dialog = getVideoVersionDialog( + requireContext(), + viewModel.item, + onItemSelected = { sourceIndex -> + createDownloadPreparingDialog() + viewModel.download(sourceIndex, storageIndex) + }, + onCancel = { + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + ) + dialog.show() + return@getStorageSelectionDialog + } + createDownloadPreparingDialog() + viewModel.download(storageIndex = storageIndex) + }, + onCancel = { + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + ) + storageDialog.show() + return@setOnClickListener + } + if (viewModel.item.sources.size > 1) { + val dialog = getVideoVersionDialog( + requireContext(), + viewModel.item, + onItemSelected = { sourceIndex -> + createDownloadPreparingDialog() + viewModel.download(sourceIndex) + }, + onCancel = { + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + ) + dialog.show() + return@setOnClickListener + } + createDownloadPreparingDialog() + viewModel.download() } - - binding.checkButton.isVisible = false - binding.favoriteButton.isVisible = false - binding.downloadButtonWrapper.isVisible = false } return binding.root @@ -150,50 +222,41 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } } + override fun onResume() { + super.onResume() + + viewModel.loadEpisode(args.episodeId) + } + private fun bindUiStateNormal(uiState: EpisodeBottomSheetViewModel.UiState.Normal) { uiState.apply { - if (episode.userData?.playedPercentage != null) { + val canDownload = episode.canDownload && episode.sources.any { it.type == FindroidSourceType.REMOTE } + val canDelete = episode.sources.any { it.type == FindroidSourceType.LOCAL } + + if (episode.playbackPositionTicks > 0) { binding.progressBar.layoutParams.width = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, - (episode.userData?.playedPercentage?.times(1.26))!!.toFloat(), + (episode.playbackPositionTicks.div(episode.runtimeTicks).times(1.26)).toFloat(), context?.resources?.displayMetrics ).toInt() binding.progressBar.isVisible = true } - val clickable = canPlay && (available || canRetry) - binding.playButton.isEnabled = clickable - binding.playButton.alpha = if (!clickable) 0.5F else 1.0F - binding.playButton.setImageResource(if (!canRetry) CoreR.drawable.ic_play else CoreR.drawable.ic_rotate_ccw) - if (!(available || canRetry)) { - binding.playButton.setImageResource(android.R.color.transparent) - binding.progressCircular.isVisible = true + val canPlay = episode.canPlay && episode.sources.isNotEmpty() + binding.itemActions.playButton.isEnabled = canPlay + binding.itemActions.playButton.alpha = if (!canPlay) 0.5F else 1.0F + + bindCheckButtonState(episode.played) + + bindFavoriteButtonState(episode.favorite) + + if (episode.isDownloaded()) { + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_trash) } - // Check icon - when (played) { - true -> binding.checkButton.setTintColor(CoreR.color.red, requireActivity().theme) - false -> binding.checkButton.setTintColorAttribute(MaterialR.attr.colorOnSecondaryContainer, requireActivity().theme) - } - - // Favorite icon - val favoriteDrawable = when (favorite) { - true -> CoreR.drawable.ic_heart_filled - false -> CoreR.drawable.ic_heart - } - binding.favoriteButton.setImageResource(favoriteDrawable) - if (favorite) binding.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme) - - when (canDownload) { - true -> { - binding.downloadButtonWrapper.isVisible = true - binding.downloadButton.isEnabled = !downloaded - - if (downloaded) binding.downloadButton.setTintColor(CoreR.color.red, requireActivity().theme) - } - false -> { - binding.downloadButtonWrapper.isVisible = false - } + when (canDownload || canDelete) { + true -> binding.itemActions.downloadButton.isVisible = true + false -> binding.itemActions.downloadButton.isVisible = false } binding.episodeName.text = getString( @@ -204,18 +267,13 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { ) binding.seriesName.text = episode.seriesName binding.overview.text = episode.overview - binding.year.text = dateString - binding.playtime.text = runTime + binding.year.text = formatDateTime(episode.premiereDate) + binding.playtime.text = getString(CoreR.string.runtime_minutes, episode.runtimeTicks.div(600000000)) binding.communityRating.isVisible = episode.communityRating != null binding.communityRating.text = episode.communityRating.toString() - binding.missingIcon.isVisible = episode.locationType == LocationType.VIRTUAL + binding.missingIcon.isVisible = false - binding.seriesName.setOnClickListener { - if (episode.seriesId != null) { - navigateToSeries(episode.seriesId!!, episode.seriesName) - } - } - bindBaseItemImage(binding.episodeImage, episode) + bindCardItemImage(binding.episodeImage, episode) } binding.loadingIndicator.isVisible = false } @@ -231,31 +289,92 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { navigateToPlayerActivity(items.items.toTypedArray()) - binding.playButton.setImageDrawable( + binding.itemActions.playButton.setImageDrawable( ContextCompat.getDrawable( requireActivity(), CoreR.drawable.ic_play ) ) - binding.progressCircular.visibility = View.INVISIBLE + binding.itemActions.progressCircular.visibility = View.INVISIBLE + } + + private fun bindCheckButtonState(played: Boolean) { + when (played) { + true -> binding.itemActions.checkButton.setTintColor(CoreR.color.red, requireActivity().theme) + false -> binding.itemActions.checkButton.setTintColorAttribute( + MaterialR.attr.colorOnSecondaryContainer, + requireActivity().theme + ) + } + } + + private fun bindFavoriteButtonState(favorite: Boolean) { + val favoriteDrawable = when (favorite) { + true -> CoreR.drawable.ic_heart_filled + false -> CoreR.drawable.ic_heart + } + binding.itemActions.favoriteButton.setImageResource(favoriteDrawable) + when (favorite) { + true -> binding.itemActions.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme) + false -> binding.itemActions.favoriteButton.setTintColorAttribute( + MaterialR.attr.colorOnSecondaryContainer, + requireActivity().theme + ) + } } private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) { Timber.e(error.error.message) binding.playerItemsError.isVisible = true - binding.playButton.setImageDrawable( + binding.itemActions.playButton.setImageDrawable( ContextCompat.getDrawable( requireActivity(), CoreR.drawable.ic_play ) ) - binding.progressCircular.visibility = View.INVISIBLE + binding.itemActions.progressCircular.visibility = View.INVISIBLE binding.playerItemsErrorDetails.setOnClickListener { ErrorDialogFragment.newInstance(error.error).show(parentFragmentManager, ErrorDialogFragment.TAG) } } + private fun createErrorDialog(uiText: UiText) { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder + .setTitle(CoreR.string.downloading_error) + .setMessage(uiText.asString(requireContext().resources)) + .setPositiveButton(getString(CoreR.string.close)) { _, _ -> + } + builder.show() + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + + private fun createDownloadPreparingDialog() { + val builder = MaterialAlertDialogBuilder(requireContext()) + downloadPreparingDialog = builder + .setTitle(CoreR.string.preparing_download) + .setView(R.layout.preparing_download_dialog) + .setCancelable(false) + .create() + downloadPreparingDialog.show() + } + + private fun createCancelDialog() { + val builder = MaterialAlertDialogBuilder(requireContext()) + val dialog = builder + .setTitle(CoreR.string.cancel_download) + .setMessage(CoreR.string.cancel_download_message) + .setPositiveButton(CoreR.string.stop_download) { _, _ -> + viewModel.cancelDownload() + } + .setNegativeButton(CoreR.string.cancel) { _, _ -> + } + .create() + dialog.show() + } + private fun navigateToPlayerActivity( playerItems: Array, ) { @@ -266,13 +385,19 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { ) } - private fun navigateToSeries(id: UUID, name: String?) { + private fun navigateToSeries(id: UUID, name: String) { findNavController().navigate( - EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToMediaInfoFragment( + EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToShowFragment( itemId = id, - itemName = name, - itemType = BaseItemKind.SERIES + itemName = name ) ) } + + private fun formatDateTime(datetime: DateTime?): String { + if (datetime == null) return "" + val instant = datetime.toInstant(ZoneOffset.UTC) + val date = Date.from(instant) + return DateFormat.getDateInstance(DateFormat.SHORT).format(date) + } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt index d598eb6f..527b9727 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt @@ -17,10 +17,13 @@ import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber @AndroidEntryPoint @@ -40,10 +43,10 @@ class FavoriteFragment : Fragment() { binding.favoritesRecyclerView.adapter = FavoritesListAdapter( ViewItemListAdapter.OnClickListener { item -> - navigateToMediaInfoFragment(item) + navigateToMediaItem(item) }, HomeEpisodeListAdapter.OnClickListener { item -> - navigateToEpisodeBottomSheetFragment(item) + navigateToMediaItem(item) } ) @@ -96,21 +99,31 @@ class FavoriteFragment : Fragment() { checkIfLoginRequired(uiState.error.message) } - private fun navigateToMediaInfoFragment(item: BaseItemDto) { - findNavController().navigate( - FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment( - item.id, - item.name, - item.type - ) - ) - } - - private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { - findNavController().navigate( - FavoriteFragmentDirections.actionFavoriteFragmentToEpisodeBottomSheetFragment( - episode.id - ) - ) + private fun navigateToMediaItem(item: FindroidItem) { + when (item) { + is FindroidMovie -> { + findNavController().navigate( + FavoriteFragmentDirections.actionFavoriteFragmentToMovieFragment( + item.id, + item.name + ) + ) + } + is FindroidShow -> { + findNavController().navigate( + FavoriteFragmentDirections.actionFavoriteFragmentToShowFragment( + item.id, + item.name + ) + ) + } + is FindroidEpisode -> { + findNavController().navigate( + FavoriteFragmentDirections.actionFavoriteFragmentToEpisodeBottomSheetFragment( + item.id + ) + ) + } + } } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt index d39d7d15..4112b484 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt @@ -8,8 +8,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.widget.Toast -import android.widget.Toast.LENGTH_LONG import androidx.appcompat.widget.SearchView import androidx.core.view.MenuHost import androidx.core.view.MenuProvider @@ -21,17 +19,22 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint +import dev.jdtech.jellyfin.AppPreferences import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.adapters.ViewListAdapter import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.databinding.FragmentHomeBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow import dev.jdtech.jellyfin.utils.checkIfLoginRequired +import dev.jdtech.jellyfin.utils.restart import dev.jdtech.jellyfin.viewmodels.HomeViewModel +import javax.inject.Inject import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber @AndroidEntryPoint @@ -44,6 +47,9 @@ class HomeFragment : Fragment() { private lateinit var errorDialog: ErrorDialogFragment + @Inject + lateinit var appPreferences: AppPreferences + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -65,7 +71,6 @@ class HomeFragment : Fragment() { object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(CoreR.menu.home_menu, menu) - val settings = menu.findItem(CoreR.id.action_settings) val search = menu.findItem(CoreR.id.action_search) val searchView = search.actionView as SearchView @@ -142,15 +147,14 @@ class HomeFragment : Fragment() { binding.viewsRecyclerView.adapter = ViewListAdapter( onClickListener = ViewListAdapter.OnClickListener { navigateToLibraryFragment(it) }, onItemClickListener = ViewItemListAdapter.OnClickListener { - navigateToMediaInfoFragment(it) + navigateToMediaItem(it) }, onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item -> - when (item.type) { - BaseItemKind.EPISODE -> navigateToEpisodeBottomSheetFragment(item) - BaseItemKind.MOVIE -> navigateToMediaInfoFragment(item) - else -> Toast.makeText(requireContext(), CoreR.string.unknown_error, LENGTH_LONG) - .show() - } + navigateToMediaItem(item) + }, + onOnlineClickListener = ViewListAdapter.OnClickListenerOfflineCard { + appPreferences.offlineMode = false + activity?.restart() } ) @@ -212,34 +216,34 @@ class HomeFragment : Fragment() { ) } - private fun navigateToMediaInfoFragment(item: BaseItemDto) { - if (item.type == BaseItemKind.EPISODE) { - findNavController().navigate( - HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment( - item.seriesId!!, - item.seriesName, - BaseItemKind.SERIES + private fun navigateToMediaItem(item: FindroidItem) { + when (item) { + is FindroidMovie -> { + findNavController().navigate( + HomeFragmentDirections.actionNavigationHomeToMovieFragment( + item.id, + item.name + ) ) - ) - } else { - findNavController().navigate( - HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment( - item.id, - item.name, - item.type + } + is FindroidShow -> { + findNavController().navigate( + HomeFragmentDirections.actionNavigationHomeToShowFragment( + item.id, + item.name + ) ) - ) + } + is FindroidEpisode -> { + findNavController().navigate( + HomeFragmentDirections.actionNavigationHomeToEpisodeBottomSheetFragment( + item.id + ) + ) + } } } - private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { - findNavController().navigate( - HomeFragmentDirections.actionNavigationHomeToEpisodeBottomSheetFragment( - episode.id - ) - ) - } - private fun navigateToSettingsFragment() { findNavController().navigate( HomeFragmentDirections.actionHomeFragmentToSettingsFragment() diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt index 086baac8..eef0f054 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt @@ -26,13 +26,16 @@ import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.SortDialogFragment import dev.jdtech.jellyfin.models.CollectionType +import dev.jdtech.jellyfin.models.FindroidCollection +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow import dev.jdtech.jellyfin.models.SortBy import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.LibraryViewModel import java.lang.IllegalArgumentException import javax.inject.Inject import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.SortOrder @AndroidEntryPoint @@ -114,9 +117,9 @@ class LibraryFragment : Fragment() { ViewItemPagingAdapter( ViewItemPagingAdapter.OnClickListener { item -> if (args.libraryType == CollectionType.BoxSets.type) { - navigateToCollectionFragment(item) + navigateToItem(item) } else { - navigateToMediaInfoFragment(item) + navigateToItem(item) } } ) @@ -197,22 +200,32 @@ class LibraryFragment : Fragment() { checkIfLoginRequired(uiState.error.message) } - private fun navigateToMediaInfoFragment(item: BaseItemDto) { - findNavController().navigate( - LibraryFragmentDirections.actionLibraryFragmentToMediaInfoFragment( - item.id, - item.name, - item.type - ) - ) - } - - private fun navigateToCollectionFragment(collection: BaseItemDto) { - findNavController().navigate( - LibraryFragmentDirections.actionLibraryFragmentToCollectionFragment( - collection.id, - collection.name - ) - ) + private fun navigateToItem(item: FindroidItem) { + when (item) { + is FindroidMovie -> { + findNavController().navigate( + LibraryFragmentDirections.actionLibraryFragmentToMovieFragment( + item.id, + item.name + ) + ) + } + is FindroidShow -> { + findNavController().navigate( + LibraryFragmentDirections.actionLibraryFragmentToShowFragment( + item.id, + item.name + ) + ) + } + is FindroidCollection -> { + findNavController().navigate( + LibraryFragmentDirections.actionLibraryFragmentToCollectionFragment( + item.id, + item.name + ) + ) + } + } } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt index 55f5d39c..c47699b1 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt @@ -23,10 +23,10 @@ import dev.jdtech.jellyfin.adapters.CollectionListAdapter import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.databinding.FragmentMediaBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.FindroidCollection import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.MediaViewModel import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber @AndroidEntryPoint @@ -146,12 +146,12 @@ class MediaFragment : Fragment() { checkIfLoginRequired(uiState.error.message) } - private fun navigateToLibraryFragment(library: BaseItemDto) { + private fun navigateToLibraryFragment(library: FindroidCollection) { findNavController().navigate( MediaFragmentDirections.actionNavigationMediaToLibraryFragment( library.id, library.name, - library.collectionType, + library.type.type, ) ) } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt deleted file mode 100644 index deeb145c..00000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ /dev/null @@ -1,451 +0,0 @@ -package dev.jdtech.jellyfin.fragments - -import android.content.Intent -import android.content.res.ColorStateList -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import com.google.android.material.R as MaterialR -import dagger.hilt.android.AndroidEntryPoint -import dev.jdtech.jellyfin.AppPreferences -import dev.jdtech.jellyfin.adapters.PersonListAdapter -import dev.jdtech.jellyfin.adapters.ViewItemListAdapter -import dev.jdtech.jellyfin.bindBaseItemImage -import dev.jdtech.jellyfin.bindItemBackdropImage -import dev.jdtech.jellyfin.core.R as CoreR -import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding -import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment -import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment -import dev.jdtech.jellyfin.models.AudioCodec -import dev.jdtech.jellyfin.models.DisplayProfile -import dev.jdtech.jellyfin.models.PlayerItem -import dev.jdtech.jellyfin.utils.checkIfLoginRequired -import dev.jdtech.jellyfin.utils.setTintColor -import dev.jdtech.jellyfin.utils.setTintColorAttribute -import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel -import dev.jdtech.jellyfin.viewmodels.PlayerViewModel -import java.util.UUID -import javax.inject.Inject -import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber - -@AndroidEntryPoint -class MediaInfoFragment : Fragment() { - - private lateinit var binding: FragmentMediaInfoBinding - private val viewModel: MediaInfoViewModel by viewModels() - private val playerViewModel: PlayerViewModel by viewModels() - private val args: MediaInfoFragmentArgs by navArgs() - - lateinit var errorDialog: ErrorDialogFragment - - @Inject - lateinit var appPreferences: AppPreferences - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentMediaInfoBinding.inflate(inflater, container, false) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { uiState -> - Timber.d("$uiState") - when (uiState) { - is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState) - is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading() - is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState) - } - } - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - if (!args.isOffline) { - viewModel.loadData(args.itemId, args.itemType) - } else { - viewModel.loadData(args.playerItem!!) - } - } - } - - if (args.itemType != BaseItemKind.MOVIE) { - binding.downloadButton.visibility = View.GONE - } - - binding.errorLayout.errorRetryButton.setOnClickListener { - viewModel.loadData(args.itemId, args.itemType) - } - - playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> - when (playerItems) { - is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) - is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems) - } - } - - binding.trailerButton.setOnClickListener { - if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener - val intent = Intent( - Intent.ACTION_VIEW, - Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url) - ) - startActivity(intent) - } - - binding.nextUp.setOnClickListener { - navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!) - } - - binding.seasonsRecyclerView.adapter = - ViewItemListAdapter( - ViewItemListAdapter.OnClickListener { season -> - navigateToSeasonFragment(season) - }, - fixedWidth = true - ) - binding.peopleRecyclerView.adapter = PersonListAdapter { person -> - navigateToPersonDetail(person.id) - } - - binding.playButton.setOnClickListener { - binding.playButton.setImageResource(android.R.color.transparent) - binding.progressCircular.isVisible = true - if (viewModel.canRetry) { - binding.playButton.isEnabled = false - viewModel.download() - return@setOnClickListener - } - viewModel.item?.let { item -> - if (!args.isOffline) { - playerViewModel.loadPlayerItems(item) { - VideoVersionDialogFragment(item, playerViewModel).show( - parentFragmentManager, - "videoversiondialog" - ) - } - } else { - playerViewModel.loadOfflinePlayerItems(args.playerItem!!) - } - } - } - - if (!args.isOffline) { - binding.errorLayout.errorRetryButton.setOnClickListener { - viewModel.loadData(args.itemId, args.itemType) - } - - binding.errorLayout.errorDetailsButton.setOnClickListener { - errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) - } - - binding.checkButton.setOnClickListener { - when (viewModel.played) { - true -> { - viewModel.markAsUnplayed(args.itemId) - binding.checkButton.setTintColorAttribute( - MaterialR.attr.colorOnSecondaryContainer, - requireActivity().theme - ) - } - - false -> { - viewModel.markAsPlayed(args.itemId) - binding.checkButton.setTintColor(CoreR.color.red, requireActivity().theme) - } - } - } - - binding.favoriteButton.setOnClickListener { - when (viewModel.favorite) { - true -> { - viewModel.unmarkAsFavorite(args.itemId) - binding.favoriteButton.setImageResource(CoreR.drawable.ic_heart) - binding.favoriteButton.setTintColorAttribute( - MaterialR.attr.colorOnSecondaryContainer, - requireActivity().theme - ) - } - - false -> { - viewModel.markAsFavorite(args.itemId) - binding.favoriteButton.setImageResource(CoreR.drawable.ic_heart_filled) - binding.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme) - } - } - } - - binding.downloadButton.setOnClickListener { - binding.downloadButton.isEnabled = false - viewModel.download() - binding.downloadButton.imageTintList = ColorStateList.valueOf( - resources.getColor( - CoreR.color.red, - requireActivity().theme - ) - ) - } - } else { - binding.favoriteButton.isVisible = false - binding.checkButton.isVisible = false - binding.downloadButton.isVisible = false - binding.deleteButton.isVisible = true - - binding.deleteButton.setOnClickListener { - viewModel.deleteItem() - findNavController().navigate(CoreR.id.downloadFragment) - } - } - } - - private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) { - uiState.apply { - binding.originalTitle.isVisible = item.originalTitle != item.name - if (item.remoteTrailers.isNullOrEmpty()) { - binding.trailerButton.isVisible = false - } - binding.communityRating.isVisible = item.communityRating != null - binding.actors.isVisible = actors.isNotEmpty() - - val clickable = canPlay && (available || canRetry) - binding.playButton.isEnabled = clickable - binding.playButton.alpha = if (!clickable) 0.5F else 1.0F - binding.playButton.setImageResource(if (!canRetry) CoreR.drawable.ic_play else CoreR.drawable.ic_rotate_ccw) - if (!(available || canRetry)) { - binding.playButton.setImageResource(android.R.color.transparent) - binding.progressCircular.isVisible = true - } - - // Check icon - when (played) { - true -> binding.checkButton.setTintColor(CoreR.color.red, requireActivity().theme) - false -> binding.checkButton.setTintColorAttribute( - MaterialR.attr.colorOnSecondaryContainer, - requireActivity().theme - ) - } - - // Favorite icon - val favoriteDrawable = when (favorite) { - true -> CoreR.drawable.ic_heart_filled - false -> CoreR.drawable.ic_heart - } - binding.favoriteButton.setImageResource(favoriteDrawable) - if (favorite) binding.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme) - - when (canDownload) { - true -> { - binding.downloadButton.isVisible = true - binding.downloadButton.isEnabled = !downloaded - - if (downloaded) binding.downloadButton.setTintColor( - CoreR.color.red, - requireActivity().theme - ) - } - - false -> { - binding.downloadButton.isVisible = false - } - } - - binding.name.text = item.name - binding.originalTitle.text = item.originalTitle - if (dateString.isEmpty()) { - binding.year.isVisible = false - } else { - binding.year.text = dateString - } - if (runTime.isEmpty()) { - binding.playtime.isVisible = false - } else { - binding.playtime.text = runTime - } - binding.officialRating.text = item.officialRating - binding.communityRating.text = item.communityRating.toString() - binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false - binding.genres.text = genresString - binding.videoMeta.text = videoString - binding.audio.text = audioString - binding.subtitles.text = subtitleString - binding.subsChip.isVisible = subtitleString.isNotEmpty() - - if (appPreferences.displayExtraInfo) { - binding.subtitlesLayout.isVisible = subtitleString.isNotEmpty() - binding.videoMetaLayout.isVisible = videoString.isNotEmpty() - binding.audioLayout.isVisible = audioString.isNotEmpty() - } - - videoMetadata?.let { - with(binding) { - videoMetaChips.isVisible = true - audioChannelChip.text = it.audioChannels.firstOrNull()?.raw - resChip.text = it.resolution.firstOrNull()?.raw - audioChannelChip.isVisible = it.audioChannels.isNotEmpty() - resChip.isVisible = it.resolution.isNotEmpty() - - it.displayProfiles.firstOrNull()?.apply { - videoProfileChip.text = this.raw - videoProfileChip.isVisible = when (this) { - DisplayProfile.HDR, - DisplayProfile.HDR10, - DisplayProfile.HLG -> { - videoProfileChip.chipStartPadding = .0f - true - } - - DisplayProfile.DOLBY_VISION -> { - videoProfileChip.isChipIconVisible = true - true - } - - else -> false - } - } - - audioCodecChip.text = when (val codec = it.audioCodecs.firstOrNull()) { - AudioCodec.AC3, AudioCodec.EAC3, AudioCodec.TRUEHD -> { - audioCodecChip.isVisible = true - if (it.isAtmos.firstOrNull() == true) { - "${codec.raw} | Atmos" - } else codec.raw - } - - AudioCodec.DTS -> { - audioCodecChip.apply { - isVisible = true - isChipIconVisible = false - chipStartPadding = .0f - } - codec.raw - } - - else -> { - audioCodecChip.isVisible = false - null - } - } - } - } - binding.directorLayout.isVisible = director != null - binding.director.text = director?.name - binding.writersLayout.isVisible = writers.isNotEmpty() - binding.writers.text = writersString - binding.description.text = item.overview - binding.nextUpLayout.isVisible = nextUp != null - binding.nextUpName.text = getString( - CoreR.string.episode_name_extended, - nextUp?.parentIndexNumber, - nextUp?.indexNumber, - nextUp?.name - ) - binding.seasonsLayout.isVisible = seasons.isNotEmpty() - val seasonsAdapter = binding.seasonsRecyclerView.adapter as ViewItemListAdapter - seasonsAdapter.submitList(seasons) - val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter - actorsAdapter.submitList(actors) - bindItemBackdropImage(binding.itemBanner, item) - bindBaseItemImage(binding.nextUpImage, nextUp) - } - binding.loadingIndicator.isVisible = false - binding.mediaInfoScrollview.isVisible = true - binding.errorLayout.errorPanel.isVisible = false - } - - private fun bindUiStateLoading() { - binding.loadingIndicator.isVisible = true - binding.errorLayout.errorPanel.isVisible = false - } - - private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) { - errorDialog = ErrorDialogFragment.newInstance(uiState.error) - binding.loadingIndicator.isVisible = false - binding.mediaInfoScrollview.isVisible = false - binding.errorLayout.errorPanel.isVisible = true - checkIfLoginRequired(uiState.error.message) - } - - private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { - navigateToPlayerActivity(items.items.toTypedArray()) - binding.playButton.setImageDrawable( - ContextCompat.getDrawable( - requireActivity(), - CoreR.drawable.ic_play - ) - ) - binding.progressCircular.visibility = View.INVISIBLE - } - - private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) { - Timber.e(error.error.message) - binding.playerItemsError.visibility = View.VISIBLE - binding.playButton.setImageDrawable( - ContextCompat.getDrawable( - requireActivity(), - CoreR.drawable.ic_play - ) - ) - binding.progressCircular.visibility = View.INVISIBLE - binding.playerItemsErrorDetails.setOnClickListener { - ErrorDialogFragment.newInstance(error.error) - .show(parentFragmentManager, ErrorDialogFragment.TAG) - } - } - - private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { - findNavController().navigate( - MediaInfoFragmentDirections.actionMediaInfoFragmentToEpisodeBottomSheetFragment( - episode.id - ) - ) - } - - private fun navigateToSeasonFragment(season: BaseItemDto) { - findNavController().navigate( - MediaInfoFragmentDirections.actionMediaInfoFragmentToSeasonFragment( - season.seriesId!!, - season.id, - season.seriesName, - season.name - ) - ) - } - - private fun navigateToPlayerActivity( - playerItems: Array, - ) { - findNavController().navigate( - MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity( - playerItems - ) - ) - } - - private fun navigateToPersonDetail(personId: UUID) { - findNavController().navigate( - MediaInfoFragmentDirections.actionMediaInfoFragmentToPersonDetailFragment(personId) - ) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt new file mode 100644 index 00000000..b55dd78c --- /dev/null +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt @@ -0,0 +1,511 @@ +package dev.jdtech.jellyfin.fragments + +import android.app.DownloadManager +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.R as MaterialR +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import dev.jdtech.jellyfin.AppPreferences +import dev.jdtech.jellyfin.R +import dev.jdtech.jellyfin.adapters.PersonListAdapter +import dev.jdtech.jellyfin.bindItemBackdropImage +import dev.jdtech.jellyfin.core.R as CoreR +import dev.jdtech.jellyfin.databinding.FragmentMovieBinding +import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.dialogs.getStorageSelectionDialog +import dev.jdtech.jellyfin.dialogs.getVideoVersionDialog +import dev.jdtech.jellyfin.models.AudioCodec +import dev.jdtech.jellyfin.models.DisplayProfile +import dev.jdtech.jellyfin.models.FindroidSourceType +import dev.jdtech.jellyfin.models.PlayerItem +import dev.jdtech.jellyfin.models.UiText +import dev.jdtech.jellyfin.models.isDownloaded +import dev.jdtech.jellyfin.models.isDownloading +import dev.jdtech.jellyfin.utils.checkIfLoginRequired +import dev.jdtech.jellyfin.utils.setTintColor +import dev.jdtech.jellyfin.utils.setTintColorAttribute +import dev.jdtech.jellyfin.viewmodels.MovieViewModel +import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.launch +import timber.log.Timber + +@AndroidEntryPoint +class MovieFragment : Fragment() { + private lateinit var binding: FragmentMovieBinding + private val viewModel: MovieViewModel by viewModels() + private val playerViewModel: PlayerViewModel by viewModels() + private val args: MovieFragmentArgs by navArgs() + + private lateinit var errorDialog: ErrorDialogFragment + private lateinit var downloadPreparingDialog: AlertDialog + + @Inject + lateinit var appPreferences: AppPreferences + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentMovieBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collect { uiState -> + Timber.d("$uiState") + when (uiState) { + is MovieViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is MovieViewModel.UiState.Loading -> bindUiStateLoading() + is MovieViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } + + launch { + viewModel.downloadStatus.collect { (status, progress) -> + when (status) { + 10 -> { + downloadPreparingDialog.dismiss() + } + DownloadManager.STATUS_PENDING -> { + binding.itemActions.downloadButton.setImageResource(android.R.color.transparent) + binding.itemActions.progressDownload.isIndeterminate = true + binding.itemActions.progressDownload.isVisible = true + } + DownloadManager.STATUS_RUNNING -> { + binding.itemActions.downloadButton.setImageResource(android.R.color.transparent) + binding.itemActions.progressDownload.isVisible = true + if (progress < 5) { + binding.itemActions.progressDownload.isIndeterminate = true + } else { + binding.itemActions.progressDownload.isIndeterminate = false + binding.itemActions.progressDownload.setProgressCompat(progress, true) + } + } + DownloadManager.STATUS_SUCCESSFUL -> { + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_trash) + binding.itemActions.progressDownload.isVisible = false + } + else -> { + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + } + } + } + + launch { + viewModel.downloadError.collect { uiText -> + createErrorDialog(uiText) + } + } + + launch { + viewModel.navigateBack.collect { + if (it) findNavController().navigateUp() + } + } + } + } + + binding.errorLayout.errorRetryButton.setOnClickListener { + viewModel.loadData(args.itemId) + } + + binding.errorLayout.errorDetailsButton.setOnClickListener { + errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) + } + + playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> + when (playerItems) { + is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) + is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems) + } + } + + binding.itemActions.playButton.setOnClickListener { + binding.itemActions.playButton.isEnabled = false + binding.itemActions.playButton.setImageResource(android.R.color.transparent) + binding.itemActions.progressCircular.isVisible = true + if (viewModel.item.sources.size > 1) { + val dialog = getVideoVersionDialog( + requireContext(), viewModel.item, + onItemSelected = { + playerViewModel.loadPlayerItems(viewModel.item, it) + }, + onCancel = { + playButtonNormal() + } + ) + dialog.show() + return@setOnClickListener + } + playerViewModel.loadPlayerItems(viewModel.item) + } + + binding.itemActions.trailerButton.setOnClickListener { + viewModel.item.trailer.let { trailerUri -> + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse(trailerUri) + ) + try { + startActivity(intent) + } catch (e: Exception) { + Toast.makeText(requireContext(), e.localizedMessage, Toast.LENGTH_SHORT).show() + } + } + } + + binding.itemActions.checkButton.setOnClickListener { + val played = viewModel.togglePlayed() + bindCheckButtonState(played) + } + + binding.itemActions.favoriteButton.setOnClickListener { + val favorite = viewModel.toggleFavorite() + bindFavoriteButtonState(favorite) + } + + binding.itemActions.downloadButton.setOnClickListener { + if (viewModel.item.isDownloaded()) { + viewModel.deleteItem() + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } else if (viewModel.item.isDownloading()) { + createCancelDialog() + } else { + binding.itemActions.downloadButton.setImageResource(android.R.color.transparent) + binding.itemActions.progressDownload.isIndeterminate = true + binding.itemActions.progressDownload.isVisible = true + if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) { + val storageDialog = getStorageSelectionDialog( + requireContext(), + onItemSelected = { storageIndex -> + if (viewModel.item.sources.size > 1) { + val dialog = getVideoVersionDialog( + requireContext(), + viewModel.item, + onItemSelected = { sourceIndex -> + createDownloadPreparingDialog() + viewModel.download(sourceIndex, storageIndex) + }, + onCancel = { + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + ) + dialog.show() + return@getStorageSelectionDialog + } + createDownloadPreparingDialog() + viewModel.download(storageIndex = storageIndex) + }, + onCancel = { + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + ) + storageDialog.show() + return@setOnClickListener + } + if (viewModel.item.sources.size > 1) { + val dialog = getVideoVersionDialog( + requireContext(), + viewModel.item, + onItemSelected = { sourceIndex -> + createDownloadPreparingDialog() + viewModel.download(sourceIndex) + }, + onCancel = { + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + ) + dialog.show() + return@setOnClickListener + } + createDownloadPreparingDialog() + viewModel.download() + } + } + + binding.peopleRecyclerView.adapter = PersonListAdapter { person -> + navigateToPersonDetail(person.id) + } + } + + override fun onResume() { + super.onResume() + + viewModel.loadData(args.itemId) + } + + private fun bindUiStateNormal(uiState: MovieViewModel.UiState.Normal) { + uiState.apply { + val canDownload = + item.canDownload && item.sources.any { it.type == FindroidSourceType.REMOTE } + val canDelete = item.sources.any { it.type == FindroidSourceType.LOCAL } + + binding.originalTitle.isVisible = item.originalTitle != item.name + if (item.trailer != null) { + binding.itemActions.trailerButton.isVisible = true + } + binding.communityRating.isVisible = item.communityRating != null + binding.actors.isVisible = actors.isNotEmpty() + + val canPlay = item.canPlay && item.sources.isNotEmpty() + binding.itemActions.playButton.isEnabled = canPlay + binding.itemActions.playButton.alpha = if (!canPlay) 0.5F else 1.0F + + bindCheckButtonState(item.played) + + bindFavoriteButtonState(item.favorite) + + if (item.isDownloaded()) { + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_trash) + } + + when (canDownload || canDelete) { + true -> binding.itemActions.downloadButton.isVisible = true + false -> binding.itemActions.downloadButton.isVisible = false + } + + binding.name.text = item.name + binding.originalTitle.text = item.originalTitle + if (dateString.isEmpty()) { + binding.year.isVisible = false + } else { + binding.year.text = dateString + } + if (runTime.isEmpty()) { + binding.playtime.isVisible = false + } else { + binding.playtime.text = runTime + } + binding.officialRating.text = item.officialRating + binding.communityRating.text = item.communityRating.toString() + binding.genresLayout.isVisible = item.genres.isNotEmpty() + binding.genres.text = genresString + binding.videoMeta.text = videoString + binding.audio.text = audioString + binding.subtitles.text = subtitleString + binding.subsChip.isVisible = subtitleString.isNotEmpty() + + if (appPreferences.displayExtraInfo) { + binding.subtitlesLayout.isVisible = subtitleString.isNotEmpty() + binding.videoMetaLayout.isVisible = videoString.isNotEmpty() + binding.audioLayout.isVisible = audioString.isNotEmpty() + } + + videoMetadata.let { + with(binding) { + videoMetaChips.isVisible = true + audioChannelChip.text = it.audioChannels.firstOrNull()?.raw + resChip.text = it.resolution.firstOrNull()?.raw + audioChannelChip.isVisible = it.audioChannels.isNotEmpty() + resChip.isVisible = it.resolution.isNotEmpty() + + it.displayProfiles.firstOrNull()?.apply { + videoProfileChip.text = this.raw + videoProfileChip.isVisible = when (this) { + DisplayProfile.HDR, + DisplayProfile.HDR10, + DisplayProfile.HLG -> { + videoProfileChip.chipStartPadding = .0f + true + } + + DisplayProfile.DOLBY_VISION -> { + videoProfileChip.isChipIconVisible = true + true + } + + else -> false + } + } + + audioCodecChip.text = when (val codec = it.audioCodecs.firstOrNull()) { + AudioCodec.AC3, AudioCodec.EAC3, AudioCodec.TRUEHD -> { + audioCodecChip.isVisible = true + if (it.isAtmos.firstOrNull() == true) { + "${codec.raw} | Atmos" + } else codec.raw + } + + AudioCodec.DTS -> { + audioCodecChip.apply { + isVisible = true + isChipIconVisible = false + chipStartPadding = .0f + } + codec.raw + } + + else -> { + audioCodecChip.isVisible = false + null + } + } + } + } + binding.directorLayout.isVisible = director != null + binding.director.text = director?.name + binding.writersLayout.isVisible = writers.isNotEmpty() + binding.writers.text = writersString + binding.description.text = item.overview + val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter + actorsAdapter.submitList(actors) + bindItemBackdropImage(binding.itemBanner, item) + } + binding.loadingIndicator.isVisible = false + binding.mediaInfoScrollview.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: MovieViewModel.UiState.Error) { + errorDialog = ErrorDialogFragment.newInstance(uiState.error) + binding.loadingIndicator.isVisible = false + binding.mediaInfoScrollview.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(uiState.error.message) + } + + private fun bindCheckButtonState(played: Boolean) { + when (played) { + true -> binding.itemActions.checkButton.setTintColor(CoreR.color.red, requireActivity().theme) + false -> binding.itemActions.checkButton.setTintColorAttribute( + MaterialR.attr.colorOnSecondaryContainer, + requireActivity().theme + ) + } + } + + private fun bindFavoriteButtonState(favorite: Boolean) { + val favoriteDrawable = when (favorite) { + true -> CoreR.drawable.ic_heart_filled + false -> CoreR.drawable.ic_heart + } + binding.itemActions.favoriteButton.setImageResource(favoriteDrawable) + when (favorite) { + true -> binding.itemActions.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme) + false -> binding.itemActions.favoriteButton.setTintColorAttribute( + MaterialR.attr.colorOnSecondaryContainer, + requireActivity().theme + ) + } + } + + private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { + navigateToPlayerActivity(items.items.toTypedArray()) + binding.itemActions.playButton.setImageDrawable( + ContextCompat.getDrawable( + requireActivity(), + CoreR.drawable.ic_play + ) + ) + binding.itemActions.progressCircular.visibility = View.INVISIBLE + } + + private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) { + Timber.e(error.error.message) + binding.playerItemsError.visibility = View.VISIBLE + playButtonNormal() + binding.playerItemsErrorDetails.setOnClickListener { + ErrorDialogFragment.newInstance(error.error) + .show(parentFragmentManager, ErrorDialogFragment.TAG) + } + } + + private fun playButtonNormal() { + binding.itemActions.playButton.isEnabled = true + binding.itemActions.playButton.setImageDrawable( + ContextCompat.getDrawable( + requireActivity(), + CoreR.drawable.ic_play + ) + ) + binding.itemActions.progressCircular.visibility = View.INVISIBLE + } + + private fun createErrorDialog(uiText: UiText) { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder + .setTitle(CoreR.string.downloading_error) + .setMessage(uiText.asString(requireContext().resources)) + .setPositiveButton(getString(CoreR.string.close)) { _, _ -> + } + builder.show() + binding.itemActions.progressDownload.isVisible = false + binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download) + } + + private fun createDownloadPreparingDialog() { + val builder = MaterialAlertDialogBuilder(requireContext()) + downloadPreparingDialog = builder + .setTitle(CoreR.string.preparing_download) + .setView(R.layout.preparing_download_dialog) + .setCancelable(false) + .create() + downloadPreparingDialog.show() + } + + private fun createCancelDialog() { + val builder = MaterialAlertDialogBuilder(requireContext()) + val dialog = builder + .setTitle(CoreR.string.cancel_download) + .setMessage(CoreR.string.cancel_download_message) + .setPositiveButton(CoreR.string.stop_download) { _, _ -> + viewModel.cancelDownload() + } + .setNegativeButton(CoreR.string.cancel) { _, _ -> + } + .create() + dialog.show() + } + + private fun navigateToPlayerActivity( + playerItems: Array, + ) { + findNavController().navigate( + MovieFragmentDirections.actionMovieFragmentToPlayerActivity( + playerItems + ) + ) + } + + private fun navigateToPersonDetail(personId: UUID) { + findNavController().navigate( + MovieFragmentDirections.actionMovieFragmentToPersonDetailFragment(personId) + ) + } +} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt index 742d2899..b0c2f00a 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt @@ -20,10 +20,12 @@ import dev.jdtech.jellyfin.bindItemImage import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.databinding.FragmentPersonDetailBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber @AndroidEntryPoint @@ -118,7 +120,7 @@ internal class PersonDetailFragment : Fragment() { private fun adapter() = ViewItemListAdapter( fixedWidth = true, - onClickListener = ViewItemListAdapter.OnClickListener { navigateToMediaInfoFragment(it) } + onClickListener = ViewItemListAdapter.OnClickListener { navigateToMediaItem(it) } ) private fun setupOverviewExpansion() = binding.overview.post { @@ -137,13 +139,24 @@ internal class PersonDetailFragment : Fragment() { } } - private fun navigateToMediaInfoFragment(item: BaseItemDto) { - findNavController().navigate( - PersonDetailFragmentDirections.actionPersonDetailFragmentToMediaInfoFragment( - itemId = item.id, - itemName = item.name, - itemType = item.type - ) - ) + private fun navigateToMediaItem(item: FindroidItem) { + when (item) { + is FindroidMovie -> { + findNavController().navigate( + PersonDetailFragmentDirections.actionPersonDetailFragmentToMovieFragment( + itemId = item.id, + itemName = item.name + ) + ) + } + is FindroidShow -> { + findNavController().navigate( + PersonDetailFragmentDirections.actionPersonDetailFragmentToShowFragment( + itemId = item.id, + itemName = item.name + ) + ) + } + } } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt index 75729357..c8834fa0 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt @@ -18,10 +18,13 @@ import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber @AndroidEntryPoint @@ -42,10 +45,10 @@ class SearchResultFragment : Fragment() { binding.searchResultsRecyclerView.adapter = FavoritesListAdapter( ViewItemListAdapter.OnClickListener { item -> - navigateToMediaInfoFragment(item) + navigateToMediaItem(item) }, HomeEpisodeListAdapter.OnClickListener { item -> - navigateToEpisodeBottomSheetFragment(item) + navigateToMediaItem(item) } ) @@ -104,21 +107,31 @@ class SearchResultFragment : Fragment() { checkIfLoginRequired(uiState.error.message) } - private fun navigateToMediaInfoFragment(item: BaseItemDto) { - findNavController().navigate( - FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment( - item.id, - item.name, - item.type - ) - ) - } - - private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { - findNavController().navigate( - FavoriteFragmentDirections.actionFavoriteFragmentToEpisodeBottomSheetFragment( - episode.id - ) - ) + private fun navigateToMediaItem(item: FindroidItem) { + when (item) { + is FindroidMovie -> { + findNavController().navigate( + SearchResultFragmentDirections.actionSearchResultFragmentToMovieFragment( + item.id, + item.name + ) + ) + } + is FindroidShow -> { + findNavController().navigate( + SearchResultFragmentDirections.actionSearchResultFragmentToShowFragment( + item.id, + item.name + ) + ) + } + is FindroidEpisode -> { + findNavController().navigate( + SearchResultFragmentDirections.actionSearchResultFragmentToEpisodeBottomSheetFragment( + item.id + ) + ) + } + } } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt index d10dfccb..d944d400 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt @@ -16,10 +16,12 @@ import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.adapters.EpisodeListAdapter import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.checkIfLoginRequired +import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.SeasonViewModel import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber @AndroidEntryPoint @@ -27,6 +29,7 @@ class SeasonFragment : Fragment() { private lateinit var binding: FragmentSeasonBinding private val viewModel: SeasonViewModel by viewModels() + private val playerViewModel: PlayerViewModel by viewModels() private val args: SeasonFragmentArgs by navArgs() private lateinit var errorDialog: ErrorDialogFragment @@ -45,25 +48,36 @@ class SeasonFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { uiState -> - Timber.d("$uiState") - when (uiState) { - is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState) - is SeasonViewModel.UiState.Loading -> bindUiStateLoading() - is SeasonViewModel.UiState.Error -> bindUiStateError(uiState) + launch { + viewModel.uiState.collect { uiState -> + Timber.d("$uiState") + when (uiState) { + is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is SeasonViewModel.UiState.Loading -> bindUiStateLoading() + is SeasonViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } + + launch { + viewModel.navigateBack.collect { + if (it) findNavController().navigateUp() } } } } - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.loadEpisodes(args.seriesId, args.seasonId) - } + binding.errorLayout.errorRetryButton.setOnClickListener { + viewModel.loadEpisodes(args.seriesId, args.seasonId, args.offline) } - binding.errorLayout.errorRetryButton.setOnClickListener { - viewModel.loadEpisodes(args.seriesId, args.seasonId) + playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> + when (playerItems) { + is PlayerViewModel.PlayerItems -> { + navigateToPlayerActivity(playerItems.items.toTypedArray()) + } + is PlayerViewModel.PlayerItemError -> {} + } } binding.errorLayout.errorDetailsButton.setOnClickListener { @@ -75,10 +89,15 @@ class SeasonFragment : Fragment() { EpisodeListAdapter.OnClickListener { episode -> navigateToEpisodeBottomSheetFragment(episode) }, - args.seriesId, args.seriesName, args.seasonId, args.seasonName ) } + override fun onResume() { + super.onResume() + + viewModel.loadEpisodes(args.seriesId, args.seasonId, args.offline) + } + private fun bindUiStateNormal(uiState: SeasonViewModel.UiState.Normal) { uiState.apply { val adapter = binding.episodesRecyclerView.adapter as EpisodeListAdapter @@ -102,11 +121,21 @@ class SeasonFragment : Fragment() { checkIfLoginRequired(uiState.error.message) } - private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) { + private fun navigateToEpisodeBottomSheetFragment(episode: FindroidEpisode) { findNavController().navigate( SeasonFragmentDirections.actionSeasonFragmentToEpisodeBottomSheetFragment( episode.id ) ) } + + private fun navigateToPlayerActivity( + playerItems: Array, + ) { + findNavController().navigate( + SeasonFragmentDirections.actionSeasonFragmentToPlayerActivity( + playerItems + ) + ) + } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt index 954ea187..01e1284d 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt @@ -9,6 +9,7 @@ import androidx.preference.PreferenceFragmentCompat import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.AppPreferences import dev.jdtech.jellyfin.core.R as CoreR +import dev.jdtech.jellyfin.utils.restart import javax.inject.Inject @AndroidEntryPoint @@ -36,6 +37,11 @@ class SettingsFragment : PreferenceFragmentCompat() { true } + findPreference("pref_offline_mode")?.setOnPreferenceClickListener { + activity?.restart() + true + } + findPreference("privacyPolicy")?.setOnPreferenceClickListener { val intent = Intent( Intent.ACTION_VIEW, diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt new file mode 100644 index 00000000..79ad96b4 --- /dev/null +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt @@ -0,0 +1,348 @@ +package dev.jdtech.jellyfin.fragments + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.R as MaterialR +import dagger.hilt.android.AndroidEntryPoint +import dev.jdtech.jellyfin.AppPreferences +import dev.jdtech.jellyfin.adapters.PersonListAdapter +import dev.jdtech.jellyfin.adapters.ViewItemListAdapter +import dev.jdtech.jellyfin.bindCardItemImage +import dev.jdtech.jellyfin.bindItemBackdropImage +import dev.jdtech.jellyfin.core.R as CoreR +import dev.jdtech.jellyfin.databinding.FragmentShowBinding +import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidSeason +import dev.jdtech.jellyfin.models.FindroidSourceType +import dev.jdtech.jellyfin.models.PlayerItem +import dev.jdtech.jellyfin.models.isDownloaded +import dev.jdtech.jellyfin.utils.checkIfLoginRequired +import dev.jdtech.jellyfin.utils.setTintColor +import dev.jdtech.jellyfin.utils.setTintColorAttribute +import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import dev.jdtech.jellyfin.viewmodels.ShowViewModel +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.launch +import timber.log.Timber + +@AndroidEntryPoint +class ShowFragment : Fragment() { + + private lateinit var binding: FragmentShowBinding + private val viewModel: ShowViewModel by viewModels() + private val playerViewModel: PlayerViewModel by viewModels() + private val args: ShowFragmentArgs by navArgs() + + private lateinit var errorDialog: ErrorDialogFragment + + @Inject + lateinit var appPreferences: AppPreferences + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentShowBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collect { uiState -> + Timber.d("$uiState") + when (uiState) { + is ShowViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is ShowViewModel.UiState.Loading -> bindUiStateLoading() + is ShowViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } + + launch { + viewModel.navigateBack.collect { + if (it) findNavController().navigateUp() + } + } + } + } + + // TODO make download button work for shows + binding.itemActions.downloadButton.visibility = View.GONE + + binding.errorLayout.errorRetryButton.setOnClickListener { + viewModel.loadData(args.itemId, args.offline) + } + + playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> + when (playerItems) { + is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) + is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems) + } + } + + binding.itemActions.trailerButton.setOnClickListener { + viewModel.item.trailer.let { trailerUri -> + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse(trailerUri) + ) + try { + startActivity(intent) + } catch (e: Exception) { + Toast.makeText(requireContext(), e.localizedMessage, Toast.LENGTH_SHORT).show() + } + } + } + + binding.nextUp.setOnClickListener { + navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!) + } + + binding.seasonsRecyclerView.adapter = + ViewItemListAdapter( + ViewItemListAdapter.OnClickListener { season -> + if (season is FindroidSeason) navigateToSeasonFragment(season) + }, + fixedWidth = true + ) + binding.peopleRecyclerView.adapter = PersonListAdapter { person -> + navigateToPersonDetail(person.id) + } + + binding.itemActions.playButton.setOnClickListener { + binding.itemActions.playButton.setImageResource(android.R.color.transparent) + binding.itemActions.progressCircular.isVisible = true + playerViewModel.loadPlayerItems(viewModel.item) + } + + binding.errorLayout.errorDetailsButton.setOnClickListener { + errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) + } + + binding.itemActions.checkButton.setOnClickListener { + val played = viewModel.togglePlayed() + bindCheckButtonState(played) + } + + binding.itemActions.favoriteButton.setOnClickListener { + val favorite = viewModel.toggleFavorite() + bindFavoriteButtonState(favorite) + } + } + + override fun onResume() { + super.onResume() + + viewModel.loadData(args.itemId, args.offline) + } + + private fun bindUiStateNormal(uiState: ShowViewModel.UiState.Normal) { + uiState.apply { + val downloaded = item.isDownloaded() + val canDownload = item.canDownload && item.sources.any { it.type == FindroidSourceType.REMOTE } + + binding.originalTitle.isVisible = item.originalTitle != item.name + if (item.trailer != null) { + binding.itemActions.trailerButton.isVisible = true + } + binding.communityRating.isVisible = item.communityRating != null + binding.actors.isVisible = actors.isNotEmpty() + + val canPlay = item.canPlay /*&& item.sources.isNotEmpty()*/ // TODO currently the sources of a show is always empty, we need a way to check if sources are available + binding.itemActions.playButton.isEnabled = canPlay + binding.itemActions.playButton.alpha = if (!canPlay) 0.5F else 1.0F + + bindCheckButtonState(item.played) + + bindFavoriteButtonState(item.favorite) + + when (canDownload) { + true -> { + binding.itemActions.downloadButton.isVisible = true + binding.itemActions.downloadButton.isEnabled = !downloaded + + if (downloaded) binding.itemActions.downloadButton.setTintColor( + CoreR.color.red, + requireActivity().theme + ) + } + + false -> { + binding.itemActions.downloadButton.isVisible = false + } + } + + binding.name.text = item.name + binding.originalTitle.text = item.originalTitle + if (dateString.isEmpty()) { + binding.year.isVisible = false + } else { + binding.year.text = dateString + } + if (runTime.isEmpty()) { + binding.playtime.isVisible = false + } else { + binding.playtime.text = runTime + } + binding.officialRating.text = item.officialRating + binding.communityRating.text = item.communityRating.toString() + binding.genresLayout.isVisible = item.genres.isNotEmpty() + binding.genres.text = genresString + binding.videoMeta.text = videoString + binding.audio.text = audioString + binding.subtitles.text = subtitleString + + if (appPreferences.displayExtraInfo) { + binding.subtitlesLayout.isVisible = subtitleString.isNotEmpty() + binding.videoMetaLayout.isVisible = videoString.isNotEmpty() + binding.audioLayout.isVisible = audioString.isNotEmpty() + } + + binding.directorLayout.isVisible = director != null + binding.director.text = director?.name + binding.writersLayout.isVisible = writers.isNotEmpty() + binding.writers.text = writersString + binding.description.text = item.overview + binding.nextUpLayout.isVisible = nextUp != null + binding.nextUpName.text = getString( + CoreR.string.episode_name_extended, + nextUp?.parentIndexNumber, + nextUp?.indexNumber, + nextUp?.name + ) + binding.seasonsLayout.isVisible = seasons.isNotEmpty() + val seasonsAdapter = binding.seasonsRecyclerView.adapter as ViewItemListAdapter + seasonsAdapter.submitList(seasons) + val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter + actorsAdapter.submitList(actors) + bindItemBackdropImage(binding.itemBanner, item) + if (nextUp != null) bindCardItemImage(binding.nextUpImage, nextUp!!) + } + binding.loadingIndicator.isVisible = false + binding.mediaInfoScrollview.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateLoading() { + binding.loadingIndicator.isVisible = true + binding.errorLayout.errorPanel.isVisible = false + } + + private fun bindUiStateError(uiState: ShowViewModel.UiState.Error) { + errorDialog = ErrorDialogFragment.newInstance(uiState.error) + binding.loadingIndicator.isVisible = false + binding.mediaInfoScrollview.isVisible = false + binding.errorLayout.errorPanel.isVisible = true + checkIfLoginRequired(uiState.error.message) + } + + private fun bindCheckButtonState(played: Boolean) { + when (played) { + true -> binding.itemActions.checkButton.setTintColor(CoreR.color.red, requireActivity().theme) + false -> binding.itemActions.checkButton.setTintColorAttribute( + MaterialR.attr.colorOnSecondaryContainer, + requireActivity().theme + ) + } + } + + private fun bindFavoriteButtonState(favorite: Boolean) { + val favoriteDrawable = when (favorite) { + true -> CoreR.drawable.ic_heart_filled + false -> CoreR.drawable.ic_heart + } + binding.itemActions.favoriteButton.setImageResource(favoriteDrawable) + when (favorite) { + true -> binding.itemActions.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme) + false -> binding.itemActions.favoriteButton.setTintColorAttribute( + MaterialR.attr.colorOnSecondaryContainer, + requireActivity().theme + ) + } + } + + private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { + navigateToPlayerActivity(items.items.toTypedArray()) + binding.itemActions.playButton.setImageDrawable( + ContextCompat.getDrawable( + requireActivity(), + CoreR.drawable.ic_play + ) + ) + binding.itemActions.progressCircular.visibility = View.INVISIBLE + } + + private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) { + Timber.e(error.error.message) + binding.playerItemsError.visibility = View.VISIBLE + binding.itemActions.playButton.setImageDrawable( + ContextCompat.getDrawable( + requireActivity(), + CoreR.drawable.ic_play + ) + ) + binding.itemActions.progressCircular.visibility = View.INVISIBLE + binding.playerItemsErrorDetails.setOnClickListener { + ErrorDialogFragment.newInstance(error.error) + .show(parentFragmentManager, ErrorDialogFragment.TAG) + } + } + + private fun navigateToEpisodeBottomSheetFragment(episode: FindroidItem) { + findNavController().navigate( + ShowFragmentDirections.actionShowFragmentToEpisodeBottomSheetFragment( + episode.id + ) + ) + } + + private fun navigateToSeasonFragment(season: FindroidSeason) { + findNavController().navigate( + ShowFragmentDirections.actionShowFragmentToSeasonFragment( + season.seriesId, + season.id, + season.seriesName, + season.name, + args.offline + ) + ) + } + + private fun navigateToPlayerActivity( + playerItems: Array, + ) { + findNavController().navigate( + ShowFragmentDirections.actionShowFragmentToPlayerActivity( + playerItems + ) + ) + } + + private fun navigateToPersonDetail(personId: UUID) { + findNavController().navigate( + ShowFragmentDirections.actionShowFragmentToPersonDetailFragment(personId) + ) + } +} diff --git a/app/phone/src/main/res/layout-w600dp/fragment_media_info.xml b/app/phone/src/main/res/layout-w600dp/fragment_show.xml similarity index 75% rename from app/phone/src/main/res/layout-w600dp/fragment_media_info.xml rename to app/phone/src/main/res/layout-w600dp/fragment_show.xml index f7291e5c..1a7be92c 100644 --- a/app/phone/src/main/res/layout-w600dp/fragment_media_info.xml +++ b/app/phone/src/main/res/layout-w600dp/fragment_show.xml @@ -22,7 +22,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - tools:context=".fragments.MediaInfoFragment"> + tools:context=".fragments.ShowFragment"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_marginBottom="16dp" /> + type="dev.jdtech.jellyfin.models.FindroidItem" /> - - - + app:layout_constraintTop_toTopOf="@id/item_image"> + + + + + + + + + \ No newline at end of file diff --git a/app/phone/src/main/res/layout/card_offline.xml b/app/phone/src/main/res/layout/card_offline.xml new file mode 100644 index 00000000..1a2975de --- /dev/null +++ b/app/phone/src/main/res/layout/card_offline.xml @@ -0,0 +1,47 @@ + + + + + + + + + +