├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug.yml
│ ├── new-feature.yml
│ └── question.yml
├── release.yml
└── workflows
│ ├── main.yml
│ ├── pr.yml
│ └── release.yml
├── .gitignore
├── .gitmodules
├── .pre-commit-config.yaml
├── CONTRIBUTING.md
├── DEVELOPMENT.md
├── LICENSE
├── README.md
├── apollo-compiler
├── .gitignore
├── build.gradle.kts
└── src
│ └── main
│ ├── java
│ └── com
│ │ └── github
│ │ └── damontecres
│ │ └── apollo
│ │ └── compiler
│ │ ├── StashApolloCompilerPlugin.kt
│ │ └── StashApolloCompilerPluginProvider.kt
│ └── resources
│ └── META-INF
│ └── services
│ └── com.apollographql.apollo.compiler.ApolloCompilerPluginProvider
├── app
├── .gitignore
├── build.gradle.kts
├── schemas
│ └── com.github.damontecres.stashapp.data.room.AppDatabase
│ │ ├── 4.json
│ │ └── 5.json
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── github
│ │ └── damontecres
│ │ └── stashapp
│ │ └── TestDbMigrations.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── assets
│ │ ├── LICENSE
│ │ └── licenses
│ │ │ ├── fontawesome.txt
│ │ │ ├── glide.txt
│ │ │ ├── previewseekbar.txt
│ │ │ └── zoomlayout.txt
│ ├── graphql
│ │ ├── Configuration.graphql
│ │ ├── FindGalleries.graphql
│ │ ├── FindGroups.graphql
│ │ ├── FindImages.graphql
│ │ ├── FindPerformers.graphql
│ │ ├── FindSavedFilter.graphql
│ │ ├── FindSceneMarkers.graphql
│ │ ├── FindScenes.graphql
│ │ ├── FindStudios.graphql
│ │ ├── FindTags.graphql
│ │ ├── Fragments.graphql
│ │ ├── ImageUpdate.graphql
│ │ ├── README.md
│ │ ├── RunPluginTask.graphql
│ │ ├── SaveSceneActivity.graphql
│ │ ├── SceneUpdate.graphql
│ │ ├── ServerInfo.graphql
│ │ ├── Subscriptions.graphql
│ │ └── SystemStatus.graphql
│ ├── java
│ │ └── com
│ │ │ └── github
│ │ │ └── damontecres
│ │ │ └── stashapp
│ │ │ ├── DebugFragment.kt
│ │ │ ├── DetailsFragment.kt
│ │ │ ├── FilterDebugFragment.kt
│ │ │ ├── FilterFragment.kt
│ │ │ ├── GalleryDetailsFragment.kt
│ │ │ ├── GalleryFragment.kt
│ │ │ ├── GroupDetailsFragment.kt
│ │ │ ├── GroupFragment.kt
│ │ │ ├── LicenseFragment.kt
│ │ │ ├── MainFragment.kt
│ │ │ ├── MarkerDetailsFragment.kt
│ │ │ ├── PerformerDetailsFragment.kt
│ │ │ ├── PerformerFragment.kt
│ │ │ ├── PinFragment.kt
│ │ │ ├── PinFragmentCompose.kt
│ │ │ ├── RootActivity.kt
│ │ │ ├── SceneDetailsFragment.kt
│ │ │ ├── SearchForFragment.kt
│ │ │ ├── SettingsFragment.kt
│ │ │ ├── SettingsUiFragment.kt
│ │ │ ├── StashApplication.kt
│ │ │ ├── StashDataGridFragment.kt
│ │ │ ├── StashExoPlayer.kt
│ │ │ ├── StashGridControlsFragment.kt
│ │ │ ├── StashSearchFragment.kt
│ │ │ ├── StudioDetailsFragment.kt
│ │ │ ├── StudioFragment.kt
│ │ │ ├── TabbedFragment.kt
│ │ │ ├── TagDetailsFragment.kt
│ │ │ ├── TagFragment.kt
│ │ │ ├── UpdateAppFragment.kt
│ │ │ ├── UpdateChangelogFragment.kt
│ │ │ ├── actions
│ │ │ ├── CreateMarkerAction.kt
│ │ │ ├── StashAction.kt
│ │ │ └── StashActionClickedListener.kt
│ │ │ ├── api
│ │ │ └── fragment
│ │ │ │ ├── GroupRelationshipData.kt
│ │ │ │ └── StashData.kt
│ │ │ ├── data
│ │ │ ├── DataType.kt
│ │ │ ├── JobResult.kt
│ │ │ ├── OCounter.kt
│ │ │ ├── PlaylistItem.kt
│ │ │ ├── Scene.kt
│ │ │ ├── SortAndDirection.kt
│ │ │ ├── SortOption.kt
│ │ │ ├── StashFindFilter.kt
│ │ │ ├── ThrottledLiveData.kt
│ │ │ ├── VideoFilter.kt
│ │ │ └── room
│ │ │ │ ├── AppDatabase.kt
│ │ │ │ ├── Converters.kt
│ │ │ │ ├── Migrations.kt
│ │ │ │ ├── PlaybackEffect.kt
│ │ │ │ ├── PlaybackEffectsDao.kt
│ │ │ │ ├── RecentSearchItem.kt
│ │ │ │ └── RecentSearchItemsDao.kt
│ │ │ ├── filter
│ │ │ ├── CreateFilterFragment.kt
│ │ │ ├── CreateFilterGuidedStepFragment.kt
│ │ │ ├── CreateFilterStep.kt
│ │ │ ├── CreateFilterViewModel.kt
│ │ │ ├── CreateFindFilterFragment.kt
│ │ │ ├── CreateObjectFilterStep.kt
│ │ │ ├── DescriptionExtractors.kt
│ │ │ ├── FilterOption.kt
│ │ │ ├── StashGuidedActionsStylist.kt
│ │ │ ├── output
│ │ │ │ ├── FilterOutputs.kt
│ │ │ │ └── FilterWriter.kt
│ │ │ └── picker
│ │ │ │ ├── BooleanPickerFragment.kt
│ │ │ │ ├── CircumcisionPickerFragment.kt
│ │ │ │ ├── DatePickerFragment.kt
│ │ │ │ ├── DurationPickerFragment.kt
│ │ │ │ ├── FloatPickerFragment.kt
│ │ │ │ ├── GenderPickerFragment.kt
│ │ │ │ ├── GuidedDurationPickerAction.kt
│ │ │ │ ├── HierarchicalMultiCriterionFragment.kt
│ │ │ │ ├── IntPickerFragment.kt
│ │ │ │ ├── MultiCriterionFragment.kt
│ │ │ │ ├── OrientationPickerFragment.kt
│ │ │ │ ├── RatingPickerFragment.kt
│ │ │ │ ├── ResolutionPickerFragment.kt
│ │ │ │ ├── SearchPickerFragment.kt
│ │ │ │ ├── StringPickerFragment.kt
│ │ │ │ └── TwoValuePicker.kt
│ │ │ ├── image
│ │ │ ├── EffectTransformation.kt
│ │ │ ├── ImageClipFragment.kt
│ │ │ ├── ImageController.kt
│ │ │ ├── ImageDetailsFragment.kt
│ │ │ ├── ImageFragment.kt
│ │ │ └── ImageViewFragment.kt
│ │ │ ├── navigation
│ │ │ ├── Destination.kt
│ │ │ ├── FilterAndPosition.kt
│ │ │ ├── NavigationManager.kt
│ │ │ ├── NavigationManagerCompose.kt
│ │ │ └── NavigationOnItemViewClickedListener.kt
│ │ │ ├── playback
│ │ │ ├── CodecSupport.kt
│ │ │ ├── ControllerVisibilityListenerList.kt
│ │ │ ├── PlaybackFragment.kt
│ │ │ ├── PlaybackSceneFragment.kt
│ │ │ ├── PlaybackVideoFiltersFragment.kt
│ │ │ ├── PlaylistFragment.kt
│ │ │ ├── PlaylistListFragment.kt
│ │ │ ├── PlaylistMarkersFragment.kt
│ │ │ ├── PlaylistScenesFragment.kt
│ │ │ ├── PlaylistViewModel.kt
│ │ │ ├── StashPlayerView.kt
│ │ │ ├── StreamUtils.kt
│ │ │ ├── TrackActivityPlaybackListener.kt
│ │ │ └── VideoFilterViewModel.kt
│ │ │ ├── presenters
│ │ │ ├── ActionPresenter.kt
│ │ │ ├── ClassPresenterSelector.kt
│ │ │ ├── CreateMarkerActionPresenter.kt
│ │ │ ├── FilterArgsPresenter.kt
│ │ │ ├── GalleryPresenter.kt
│ │ │ ├── GroupPresenter.kt
│ │ │ ├── GroupRelationshipPresenter.kt
│ │ │ ├── ImagePresenter.kt
│ │ │ ├── MarkerPresenter.kt
│ │ │ ├── NullPresenter.kt
│ │ │ ├── NullPresenterSelector.kt
│ │ │ ├── OCounterPresenter.kt
│ │ │ ├── PerformerInScenePresenter.kt
│ │ │ ├── PerformerPresenter.kt
│ │ │ ├── PlaylistItemPresenter.kt
│ │ │ ├── PopupOnLongClickListener.kt
│ │ │ ├── SceneDetailsPresenter.kt
│ │ │ ├── ScenePresenter.kt
│ │ │ ├── StashImageCardView.kt
│ │ │ ├── StashPresenter.kt
│ │ │ ├── StudioPresenter.kt
│ │ │ └── TagPresenter.kt
│ │ │ ├── setup
│ │ │ ├── ConfigureServerStep.kt
│ │ │ ├── ManageServersFragment.kt
│ │ │ ├── SetupFragment.kt
│ │ │ ├── SetupGuidedStepSupportFragment.kt
│ │ │ ├── SetupState.kt
│ │ │ ├── SetupStep1ServerUrl.kt
│ │ │ ├── SetupStep2Ssl.kt
│ │ │ ├── SetupStep3ApiKey.kt
│ │ │ ├── SetupStep4Pin.kt
│ │ │ └── readonly
│ │ │ │ ├── ReadOnlyPinConfigFragment.kt
│ │ │ │ └── SettingsPinEntryFragment.kt
│ │ │ ├── suppliers
│ │ │ ├── DataSupplierFactory.kt
│ │ │ ├── FilterArgs.kt
│ │ │ ├── GalleryDataSupplier.kt
│ │ │ ├── GroupDataSupplier.kt
│ │ │ ├── GroupRelationshipDataSupplier.kt
│ │ │ ├── ImageDataSupplier.kt
│ │ │ ├── MarkerDataSupplier.kt
│ │ │ ├── PerformerDataSupplier.kt
│ │ │ ├── PerformerTagDataSupplier.kt
│ │ │ ├── SceneDataSupplier.kt
│ │ │ ├── StashPagingSource.kt
│ │ │ ├── StashSparseFilterFetcher.kt
│ │ │ ├── StudioDataSupplier.kt
│ │ │ ├── TagDataSupplier.kt
│ │ │ └── VideoSceneDataSupplier.kt
│ │ │ ├── ui
│ │ │ ├── ComposeUiConfig.kt
│ │ │ ├── Extensions.kt
│ │ │ ├── FilterViewModel.kt
│ │ │ ├── NavDrawerFragment.kt
│ │ │ ├── PreviewUtils.kt
│ │ │ ├── Themes.kt
│ │ │ ├── cards
│ │ │ │ ├── Cards.kt
│ │ │ │ ├── FilterCards.kt
│ │ │ │ ├── GalleryCard.kt
│ │ │ │ ├── GroupCard.kt
│ │ │ │ ├── ImageCard.kt
│ │ │ │ ├── MarkerCard.kt
│ │ │ │ ├── PerformerCard.kt
│ │ │ │ ├── SceneCard.kt
│ │ │ │ ├── StudioCard.kt
│ │ │ │ └── TagCard.kt
│ │ │ ├── components
│ │ │ │ ├── CircularProgress.kt
│ │ │ │ ├── Dialogs.kt
│ │ │ │ ├── DotSeparatedRow.kt
│ │ │ │ ├── EditTextBox.kt
│ │ │ │ ├── ItemDetails.kt
│ │ │ │ ├── ItemOnClicker.kt
│ │ │ │ ├── ItemsRow.kt
│ │ │ │ ├── LongClicker.kt
│ │ │ │ ├── Rating.kt
│ │ │ │ ├── SavedFiltersButton.kt
│ │ │ │ ├── SliderBar.kt
│ │ │ │ ├── SortByButton.kt
│ │ │ │ ├── StashGrid.kt
│ │ │ │ ├── SwitchWithLabel.kt
│ │ │ │ ├── TabPage.kt
│ │ │ │ ├── TableRow.kt
│ │ │ │ ├── TitleValueText.kt
│ │ │ │ ├── image
│ │ │ │ │ ├── ImageControlsOverlay.kt
│ │ │ │ │ ├── ImageDetailsFooter.kt
│ │ │ │ │ ├── ImageDetailsHeader.kt
│ │ │ │ │ ├── ImageDetailsViewModel.kt
│ │ │ │ │ ├── ImageFilterSliders.kt
│ │ │ │ │ └── ImageOverlay.kt
│ │ │ │ ├── main
│ │ │ │ │ ├── MainPageHeader.kt
│ │ │ │ │ ├── MainPagePerformerDetails.kt
│ │ │ │ │ └── MainPageSceneDetails.kt
│ │ │ │ ├── playback
│ │ │ │ │ ├── AmbientPlayerListener.kt
│ │ │ │ │ ├── KeyIdentifier.kt
│ │ │ │ │ ├── PlaybackControls.kt
│ │ │ │ │ ├── PlaybackDebugInfo.kt
│ │ │ │ │ ├── PlaybackOverlay.kt
│ │ │ │ │ ├── PlaybackPageContent.kt
│ │ │ │ │ ├── PlaybackState.kt
│ │ │ │ │ ├── PlayerControls.kt
│ │ │ │ │ ├── PlaylistList.kt
│ │ │ │ │ ├── PlaylistListDialog.kt
│ │ │ │ │ ├── SceneDetailsOverlay.kt
│ │ │ │ │ ├── SeekBar.kt
│ │ │ │ │ ├── SeekBarState.kt
│ │ │ │ │ └── SkipIndicator.kt
│ │ │ │ └── scene
│ │ │ │ │ ├── SceneDetailsBody.kt
│ │ │ │ │ ├── SceneDetailsFooter.kt
│ │ │ │ │ ├── SceneDetailsHeader.kt
│ │ │ │ │ ├── SceneDetailsViewModel.kt
│ │ │ │ │ └── ScenePlayButton.kt
│ │ │ ├── pages
│ │ │ │ ├── ChooseThemePage.kt
│ │ │ │ ├── FilterPage.kt
│ │ │ │ ├── GalleryPage.kt
│ │ │ │ ├── GroupPage.kt
│ │ │ │ ├── ImagePage.kt
│ │ │ │ ├── MainPage.kt
│ │ │ │ ├── MarkerPage.kt
│ │ │ │ ├── PerformerPage.kt
│ │ │ │ ├── PinEntryPage.kt
│ │ │ │ ├── PlaybackPage.kt
│ │ │ │ ├── SceneDetailsPage.kt
│ │ │ │ ├── SearchForPage.kt
│ │ │ │ ├── SearchPage.kt
│ │ │ │ ├── StudioPage.kt
│ │ │ │ └── TagPage.kt
│ │ │ ├── theme
│ │ │ │ ├── Color.kt
│ │ │ │ └── Theme.kt
│ │ │ └── util
│ │ │ │ ├── CoilPreviewTransformation.kt
│ │ │ │ ├── CrossFadeFactory.kt
│ │ │ │ ├── ModifierUtils.kt
│ │ │ │ └── OneTimeLaunchedEffect.kt
│ │ │ ├── util
│ │ │ ├── AlphabetSearchUtils.kt
│ │ │ ├── AndroidExtensions.kt
│ │ │ ├── AppUpgradeHandler.kt
│ │ │ ├── Comparators.kt
│ │ │ ├── ComposePager.kt
│ │ │ ├── Constants.kt
│ │ │ ├── CreateNew.kt
│ │ │ ├── DefaultKeyEventCallback.kt
│ │ │ ├── FilterParser.kt
│ │ │ ├── FilterUtils.kt
│ │ │ ├── FrontPageParser.kt
│ │ │ ├── KeyEventDispatcher.kt
│ │ │ ├── ListRowManager.kt
│ │ │ ├── LongClickPreference.kt
│ │ │ ├── MappedList.kt
│ │ │ ├── MutationEngine.kt
│ │ │ ├── OCounterLongClickCallBack.kt
│ │ │ ├── OptionalSerializer.kt
│ │ │ ├── PageFilterKey.kt
│ │ │ ├── PagingObjectAdapter.kt
│ │ │ ├── QueryEngine.kt
│ │ │ ├── RemoveLongClickListener.kt
│ │ │ ├── ServerPreferences.kt
│ │ │ ├── SingleItemObjectAdapter.kt
│ │ │ ├── SkipParams.kt
│ │ │ ├── StashClient.kt
│ │ │ ├── StashCoroutineExceptionHandler.kt
│ │ │ ├── StashEngine.kt
│ │ │ ├── StashFragmentPagerAdapter.kt
│ │ │ ├── StashGlide.kt
│ │ │ ├── StashGlideModule.kt
│ │ │ ├── StashPreviewLoader.kt
│ │ │ ├── StashServer.kt
│ │ │ ├── SubscriptionEngine.kt
│ │ │ ├── UpdateChecker.kt
│ │ │ ├── Version.kt
│ │ │ ├── plugin
│ │ │ │ ├── CompanionPlugin.kt
│ │ │ │ ├── CrashReportSenderFactory.kt
│ │ │ │ └── LogcatTags.kt
│ │ │ └── svg
│ │ │ │ ├── SvgDecoder.kt
│ │ │ │ ├── SvgDrawableTranscoder.kt
│ │ │ │ └── SvgSoftwareLayerSetter.kt
│ │ │ └── views
│ │ │ ├── ClassOnItemViewClickedListener.kt
│ │ │ ├── DurationPicker.kt
│ │ │ ├── DurationPicker2.kt
│ │ │ ├── FontSpan.kt
│ │ │ ├── Formatting.kt
│ │ │ ├── HomeImageButton.kt
│ │ │ ├── LoadingFragment.kt
│ │ │ ├── MainTitleView.kt
│ │ │ ├── MarkerPickerFragment.kt
│ │ │ ├── PlayAllOnClickListener.kt
│ │ │ ├── SimpleListPopupWindow.kt
│ │ │ ├── SkipIndicator.kt
│ │ │ ├── SortButtonManager.kt
│ │ │ ├── StarRatingBar.kt
│ │ │ ├── StashOnFocusChangeListener.kt
│ │ │ ├── StashRatingBar.kt
│ │ │ ├── StashZoomImageView.kt
│ │ │ ├── TabbedGridTitleView.kt
│ │ │ ├── TitleTransitionHelper.kt
│ │ │ ├── WrapAroundSeekBar.kt
│ │ │ ├── dialog
│ │ │ └── ConfirmationDialogFragment.kt
│ │ │ └── models
│ │ │ ├── CardUiSettings.kt
│ │ │ ├── EqualityMutableLiveData.kt
│ │ │ ├── GalleryViewModel.kt
│ │ │ ├── GroupViewModel.kt
│ │ │ ├── ImageViewModel.kt
│ │ │ ├── ItemViewModel.kt
│ │ │ ├── MarkerDetailsViewModel.kt
│ │ │ ├── PerformerViewModel.kt
│ │ │ ├── PlaybackViewModel.kt
│ │ │ ├── SceneViewModel.kt
│ │ │ ├── ServerViewModel.kt
│ │ │ ├── StashGridViewModel.kt
│ │ │ ├── StudioViewModel.kt
│ │ │ ├── TabbedGridViewModel.kt
│ │ │ └── TagViewModel.kt
│ └── res
│ │ ├── anim
│ │ ├── slide_in_right.xml
│ │ └── slide_out_left.xml
│ │ ├── animator
│ │ ├── fade_in.xml
│ │ └── fade_out.xml
│ │ ├── color
│ │ ├── clickable_text.xml
│ │ ├── filter_thumb.xml
│ │ ├── seek_bar_blue.xml
│ │ ├── seek_bar_green.xml
│ │ └── seek_bar_red.xml
│ │ ├── drawable
│ │ ├── baseline_add_box_24.xml
│ │ ├── baseline_camera_indoor_48.xml
│ │ ├── baseline_fast_forward_24.xml
│ │ ├── baseline_fast_rewind_24.xml
│ │ ├── baseline_more_vert_96.xml
│ │ ├── baseline_pause_24.xml
│ │ ├── baseline_play_arrow_24.xml
│ │ ├── baseline_skip_next_24.xml
│ │ ├── baseline_skip_previous_24.xml
│ │ ├── baseline_stop_24.xml
│ │ ├── baseline_undo_24.xml
│ │ ├── button_selector.xml
│ │ ├── button_selector_default_bg.xml
│ │ ├── button_selector_marker_picker.xml
│ │ ├── captions_svgrepo_com.xml
│ │ ├── circular_arrow_right.xml
│ │ ├── default_gallery.xml
│ │ ├── default_group.xml
│ │ ├── default_image.xml
│ │ ├── default_scene.xml
│ │ ├── default_studio.xml
│ │ ├── default_tag.xml
│ │ ├── favorite_button_selector.xml
│ │ ├── guided_actions_selector.xml
│ │ ├── icon_button_selector.xml
│ │ ├── image_button_selector.xml
│ │ ├── playback_button_selector.xml
│ │ ├── playback_popup_item_background.xml
│ │ ├── popup_item_background.xml
│ │ ├── rectangle.xml
│ │ ├── scrollbar_thumb.xml
│ │ ├── search_cursor.xml
│ │ ├── selected_rectangle.xml
│ │ ├── sweat_drops.xml
│ │ ├── transparent_button_selector.xml
│ │ ├── vector_settings.xml
│ │ └── video_frame.xml
│ │ ├── font
│ │ └── fa_solid_900.ttf
│ │ ├── layout
│ │ ├── activity_root.xml
│ │ ├── activity_root_compose.xml
│ │ ├── activity_root_pin.xml
│ │ ├── alphabet_button.xml
│ │ ├── apply_video_filters.xml
│ │ ├── changelog.xml
│ │ ├── compose_frame.xml
│ │ ├── debug.xml
│ │ ├── debug_supported_row.xml
│ │ ├── details_view.xml
│ │ ├── duration_guided_action.xml
│ │ ├── exo_player_control_view.xml
│ │ ├── filter_debug.xml
│ │ ├── filter_debug_page.xml
│ │ ├── filter_debug_page_row.xml
│ │ ├── filter_list.xml
│ │ ├── frame.xml
│ │ ├── grid_footer_layout.xml
│ │ ├── group_view.xml
│ │ ├── image_action_button.xml
│ │ ├── image_card_extra_content_row.xml
│ │ ├── image_card_icon_row.xml
│ │ ├── image_clip_playback.xml
│ │ ├── image_fragment.xml
│ │ ├── image_layout.xml
│ │ ├── jump_buttons.xml
│ │ ├── lb_details_description.xml
│ │ ├── lb_image_card_view.xml
│ │ ├── license.xml
│ │ ├── loading_fragment.xml
│ │ ├── main_title_view.xml
│ │ ├── marker_picker.xml
│ │ ├── performer_details_compose.xml
│ │ ├── pin_dialog.xml
│ │ ├── playlist_item.xml
│ │ ├── playlist_list.xml
│ │ ├── popup_header.xml
│ │ ├── popup_item.xml
│ │ ├── root_fragment_layout.xml
│ │ ├── skip_indicator.xml
│ │ ├── sort_popup_item.xml
│ │ ├── stash_card_player_view.xml
│ │ ├── stash_grid.xml
│ │ ├── stash_grid_controls.xml
│ │ ├── stash_rating_bar.xml
│ │ ├── tabbed_grid_view.xml
│ │ ├── table_row.xml
│ │ ├── title.xml
│ │ └── video_playback.xml
│ │ ├── mipmap-nodpi
│ │ └── stash_logo.png
│ │ ├── values-night
│ │ └── styles.xml
│ │ ├── values
│ │ ├── arrays.xml
│ │ ├── attrs_main_title_view.xml
│ │ ├── attrs_rating_bar.xml
│ │ ├── colors.xml
│ │ ├── create_filter_strings.xml
│ │ ├── dimens.xml
│ │ ├── license.xml
│ │ ├── preferences.xml
│ │ ├── strings.xml
│ │ ├── styles.xml
│ │ ├── themes.xml
│ │ └── values.xml
│ │ └── xml
│ │ ├── advanced_preferences.xml
│ │ ├── provider_paths.xml
│ │ ├── root_preferences.xml
│ │ └── ui_preferences.xml
│ └── test
│ ├── java
│ ├── android
│ │ └── util
│ │ │ └── Log.kt
│ └── com
│ │ └── github
│ │ └── damontecres
│ │ └── stashapp
│ │ ├── FormattingTests.kt
│ │ ├── FrontPageFilterTests.kt
│ │ ├── ObjectFilterParsingTests.kt
│ │ ├── SortOptionTests.kt
│ │ └── VersionCompareTests.kt
│ └── resources
│ ├── front_page_basic.json
│ ├── front_page_unsupported.json
│ ├── gender_savedfilter.json
│ ├── image_savedfilter.json
│ ├── performer_custom_fields.json
│ ├── performer_savedfilter.json
│ ├── scene_savedfilter.json
│ ├── scene_savedfilter2.json
│ ├── studio_children_savedfilter.json
│ └── tag_savedfilter.json
├── build.gradle.kts
├── buildSrc
├── .gitignore
├── build.gradle.kts
├── settings.gradle.kts
└── src
│ └── main
│ └── java
│ └── com
│ └── github
│ └── damontecres
│ └── buildsrc
│ └── ParseStashStrings.kt
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*.kt]
4 | ktlint_function_naming_ignore_when_annotated_with=Composable
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: "Bug Report"
2 | description: Report a bug
3 | title: "[BUG] -
"
4 | labels: [
5 | "bug"
6 | ]
7 | body:
8 | - type: textarea
9 | id: description
10 | attributes:
11 | label: "Description"
12 | description: Please enter a description of the bug
13 | validations:
14 | required: true
15 | - type: textarea
16 | id: reprod
17 | attributes:
18 | label: "Reproduction steps"
19 | description: Please enter the steps to reproduce
20 | value: |
21 | 1. Click on '...'
22 | 2. Scroll to '....'
23 | 3. See error
24 | validations:
25 | required: false
26 | - type: input
27 | id: app-version
28 | attributes:
29 | label: "App Version"
30 | description: What version of the app?
31 | placeholder: "v0.5.0"
32 | validations:
33 | required: true
34 | - type: input
35 | id: server
36 | attributes:
37 | label: "Server Version"
38 | description: What version of the server?
39 | placeholder: "v0.27.2"
40 | validations:
41 | required: true
42 | - type: input
43 | id: device
44 | attributes:
45 | label: "Device"
46 | description: What device are you using?
47 | placeholder: "NVIDIA Shield TV Pro 2019"
48 | validations:
49 | required: false
50 | - type: textarea
51 | id: logs
52 | attributes:
53 | label: "Logs"
54 | description: Please [gather any logs or crash reports](https://github.com/damontecres/StashAppAndroidTV/wiki/Gathering-app-logs) and include them here.
55 | placeholder: Review logs for personal information before sharing them here publicly!
56 | render: bash
57 | validations:
58 | required: false
59 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/new-feature.yml:
--------------------------------------------------------------------------------
1 | name: "Feature Request"
2 | description: Request a new feature
3 | title: "[FEA] - "
4 | labels: [
5 | "enhancement"
6 | ]
7 | body:
8 | - type: textarea
9 | id: description
10 | attributes:
11 | label: "Description"
12 | description: Please enter a description of the feature
13 | placeholder: "I would like to see..."
14 | validations:
15 | required: true
16 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/question.yml:
--------------------------------------------------------------------------------
1 | name: "Question"
2 | description: Ask a question
3 | title: "[QST] - "
4 | labels: [
5 | "question"
6 | ]
7 | body:
8 | - type: textarea
9 | id: description
10 | attributes:
11 | label: "Question"
12 | description: Please enter your question
13 | placeholder: "How do I...?"
14 | validations:
15 | required: true
16 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | exclude:
3 | labels: []
4 | authors: []
5 | categories:
6 | - title: Breaking Changes
7 | labels:
8 | - breaking change
9 | - title: New Features & Enhancements
10 | labels:
11 | - enhancement
12 | - title: UI/UX Improvements
13 | labels:
14 | - user interface
15 | exclude:
16 | labels:
17 | - bug
18 | - new ui
19 | - title: New UI/UX Changes
20 | labels:
21 | - new ui
22 | - title: Bug Fixes
23 | labels:
24 | - bug
25 | - title: Other changes
26 | labels:
27 | - "*"
28 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: PR
2 |
3 | on:
4 | pull_request:
5 |
6 | defaults:
7 | run:
8 | shell: bash
9 |
10 | env:
11 | BUILD_TOOLS_VERSION: 35.0.0
12 | BUILD_DIRS_ARTIFACT: build-dirs
13 |
14 | jobs:
15 | pre-commit:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout the code
19 | uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 0 # Need the tags to build
22 | submodules: true # Need the submodules to build
23 | - name: Setup Python
24 | uses: actions/setup-python@v5
25 | with:
26 | python-version: '3.10'
27 | - name: Run pre-commit
28 | uses: pre-commit/action@v3.0.1
29 |
30 | build:
31 | runs-on: ubuntu-latest
32 | needs: pre-commit
33 | steps:
34 | - name: Checkout the code
35 | uses: actions/checkout@v4
36 | with:
37 | fetch-depth: 0 # Need the tags to build
38 | submodules: true # Need the submodules to build
39 | - name: Setup JDK
40 | uses: actions/setup-java@v4
41 | with:
42 | distribution: zulu
43 | java-version: '21'
44 | cache: 'gradle'
45 | - name: Setup Android SDK
46 | uses: android-actions/setup-android@v3
47 | with:
48 | packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }}"
49 | - name: Build app
50 | id: buildapp
51 | run: |
52 | ./gradlew clean build
53 | echo "apk=$(ls app/build/outputs/apk/debug/StashAppAndroidTV-debug*.apk)" >> "$GITHUB_OUTPUT"
54 | - name: Tar build dirs
55 | run: |
56 | tar -czf build.tgz */build/.
57 | - uses: actions/upload-artifact@v4
58 | id: upload-build-dirs
59 | with:
60 | name: "${{ env.BUILD_DIRS_ARTIFACT }}"
61 | path: build.tgz
62 | if-no-files-found: error
63 | - uses: actions/upload-artifact@v4
64 | with:
65 | name: StashAppAndroidTV-debug.apk
66 | path: "${{ steps.buildapp.outputs.apk }}"
67 | compression-level: 0
68 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Create release
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | defaults:
9 | run:
10 | shell: bash
11 |
12 | env:
13 | APK_SHORT_NAME: StashAppAndroidTV.apk
14 | BUILD_TOOLS_VERSION: 35.0.0
15 |
16 | jobs:
17 | publish:
18 | runs-on: ubuntu-latest
19 | permissions:
20 | contents: write
21 | steps:
22 | - name: Checkout the code
23 | uses: actions/checkout@v4
24 | with:
25 | fetch-depth: 0 # Need the tags to build
26 | submodules: true # Need the submodules to build
27 | - name: Setup Python
28 | uses: actions/setup-python@v5
29 | with:
30 | python-version: '3.10'
31 | - name: Setup JDK
32 | uses: actions/setup-java@v4
33 | with:
34 | distribution: zulu
35 | java-version: '21'
36 | cache: 'gradle'
37 | - name: Setup Android SDK
38 | uses: android-actions/setup-android@v3
39 | with:
40 | packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }}"
41 | - name: Build app
42 | id: buildapp
43 | env:
44 | KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
45 | KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
46 | KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
47 | SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
48 | run: |
49 | ./gradlew clean assembleRelease
50 | echo "apk=$(ls app/build/outputs/apk/release/StashAppAndroidTV-release*.apk)" >> "$GITHUB_OUTPUT"
51 | - name: Verify signature
52 | run: |
53 | ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs "${{ steps.buildapp.outputs.apk }}"
54 | - name: Copy APK to ${{ env.APK_SHORT_NAME }}
55 | run: |
56 | cp ${{ steps.buildapp.outputs.apk }} ${{ env.APK_SHORT_NAME }}
57 | - name: Create GitHub release
58 | uses: ncipollo/release-action@v1
59 | with:
60 | artifacts: "${{ steps.buildapp.outputs.apk }},${{ env.APK_SHORT_NAME }}"
61 | makeLatest: true
62 | prerelease: false
63 | generateReleaseNotes: true
64 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Hardcoded crentials
2 | StashCredentials.kt
3 | stash_strings.xml
4 | test-server/
5 |
6 | # Gradle files
7 | .gradle/
8 | build/
9 |
10 | # Local configuration file (sdk path, etc)
11 | local.properties
12 |
13 | # Log/OS Files
14 | *.log
15 |
16 | # Android Studio generated files and folders
17 | captures/
18 | .externalNativeBuild/
19 | .cxx/
20 | *.apk
21 | output.json
22 |
23 | # IntelliJ
24 | *.iml
25 | .idea/
26 | misc.xml
27 | deploymentTargetDropDown.xml
28 | render.experimental.xml
29 |
30 | # Keystore files
31 | *.jks
32 | *.keystore
33 |
34 | # Google Services (e.g. APIs or Firebase)
35 | google-services.json
36 |
37 | # Android Profiling
38 | *.hprof
39 |
40 | # Android Studio generated
41 | .gradle
42 | /local.properties
43 | /.idea/caches
44 | /.idea/libraries
45 | /.idea/modules.xml
46 | /.idea/workspace.xml
47 | /.idea/navEditor.xml
48 | /.idea/assetWizardSettings.xml
49 | .DS_Store
50 | /build
51 | /captures
52 | .externalNativeBuild
53 | .cxx
54 |
55 | .kotlin/
56 |
57 | schema.graphqls
58 |
59 | # Local scripts
60 | local_scripts/
61 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "stash"]
2 | path = stash-server
3 | url = https://github.com/stashapp/stash.git
4 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v5.0.0
4 | hooks:
5 | - id: check-xml
6 | - id: check-yaml
7 | - id: end-of-file-fixer
8 | - id: trailing-whitespace
9 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
10 | rev: v2.12.0
11 | hooks:
12 | - id: pretty-format-kotlin
13 | args: [ --autofix, --ktlint-version=1.4.1 ]
14 | - repo: local
15 | hooks:
16 | - id: check-debug-enabled
17 | name: Check debug enabled
18 | entry: val DEBUG = true
19 | language: pygrep
20 | files: '.+\.kt'
21 |
--------------------------------------------------------------------------------
/apollo-compiler/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/apollo-compiler/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("java-library")
3 | alias(libs.plugins.kotlin.jvm)
4 | }
5 |
6 | java {
7 | sourceCompatibility = JavaVersion.VERSION_21
8 | targetCompatibility = JavaVersion.VERSION_21
9 | }
10 |
11 | dependencies {
12 | implementation(libs.apollo.compiler)
13 | api(libs.kotlinx.serialization.core)
14 | }
15 |
--------------------------------------------------------------------------------
/apollo-compiler/src/main/java/com/github/damontecres/apollo/compiler/StashApolloCompilerPluginProvider.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.apollo.compiler
2 |
3 | import com.apollographql.apollo.annotations.ApolloExperimental
4 | import com.apollographql.apollo.compiler.ApolloCompilerPlugin
5 | import com.apollographql.apollo.compiler.ApolloCompilerPluginEnvironment
6 | import com.apollographql.apollo.compiler.ApolloCompilerPluginProvider
7 |
8 | @OptIn(ApolloExperimental::class)
9 | class StashApolloCompilerPluginProvider : ApolloCompilerPluginProvider {
10 | override fun create(environment: ApolloCompilerPluginEnvironment): ApolloCompilerPlugin = StashApolloCompilerPlugin()
11 | }
12 |
--------------------------------------------------------------------------------
/apollo-compiler/src/main/resources/META-INF/services/com.apollographql.apollo.compiler.ApolloCompilerPluginProvider:
--------------------------------------------------------------------------------
1 | com.github.damontecres.apollo.compiler.StashApolloCompilerPluginProvider
2 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/app/src/main/assets/LICENSE:
--------------------------------------------------------------------------------
1 | ../../../../LICENSE
--------------------------------------------------------------------------------
/app/src/main/assets/licenses/previewseekbar.txt:
--------------------------------------------------------------------------------
1 | PreviewSeekBar included from https://github.com/rubensousa/PreviewSeekBar
2 |
3 | Copyright 2017 The Android Open Source Project
4 | Copyright 2020 Rúben Sousa
5 |
6 | Licensed under the Apache License, Version 2.0 (the "License");
7 | you may not use this file except in compliance with the License.
8 | You may obtain a copy of the License at
9 |
10 | http://www.apache.org/licenses/LICENSE-2.0
11 |
12 | Unless required by applicable law or agreed to in writing, software
13 | distributed under the License is distributed on an "AS IS" BASIS,
14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | See the License for the specific language governing permissions and
16 | limitations under the License.
17 |
--------------------------------------------------------------------------------
/app/src/main/assets/licenses/zoomlayout.txt:
--------------------------------------------------------------------------------
1 | ZoomLayout included from https://natario1.github.io/ZoomLayout/home
2 |
3 | Copyright (C) 2020 Mattia Iavarone
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 |
--------------------------------------------------------------------------------
/app/src/main/graphql/Configuration.graphql:
--------------------------------------------------------------------------------
1 | query Configuration {
2 | configuration {
3 | defaults{
4 | scan{
5 | scanGenerateClipPreviews
6 | scanGenerateCovers
7 | scanGenerateImagePreviews
8 | scanGeneratePhashes
9 | scanGeneratePreviews
10 | scanGenerateSprites
11 | scanGenerateThumbnails
12 | }
13 | generate{
14 | clipPreviews
15 | covers
16 | imagePreviews
17 | interactiveHeatmapsSpeeds
18 | markerImagePreviews
19 | markers
20 | markerScreenshots
21 | phashes
22 | previews
23 | sprites
24 | transcodes
25 | imageThumbnails
26 | }
27 | }
28 | interface {
29 | menuItems
30 | showStudioAsText
31 | }
32 | ui
33 | }
34 | version {
35 | version
36 | hash
37 | build_time
38 | }
39 | plugins {
40 | id
41 | name
42 | version
43 | }
44 | }
45 |
46 | query ConfigurationUI {
47 | configuration {
48 | ui
49 | }
50 | }
51 |
52 | mutation MetadataScan($input: ScanMetadataInput!){
53 | metadataScan(input: $input)
54 | }
55 |
56 | mutation MetadataGenerate($input: GenerateMetadataInput!){
57 | metadataGenerate(input: $input)
58 | }
59 |
60 | mutation InstallPackages($type: PackageType!, $packages: [PackageSpecInput!]!) {
61 | installPackages(type: $type, packages: $packages)
62 | }
63 |
64 | query FindJob($input: FindJobInput!) {
65 | findJob(input: $input) {
66 | ...StashJob
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app/src/main/graphql/FindGalleries.graphql:
--------------------------------------------------------------------------------
1 | query FindGalleries($filter: FindFilterType, $gallery_filter: GalleryFilterType, $ids: [ID!]) {
2 | findGalleries(filter: $filter, gallery_filter: $gallery_filter, ids: $ids) {
3 | galleries {
4 | ...GalleryData
5 | }
6 | }
7 | }
8 |
9 | query GetGallery($id: ID!) {
10 | findGallery(id: $id) {
11 | ...GalleryData
12 | }
13 | }
14 |
15 | query CountGalleries($filter: FindFilterType, $gallery_filter: GalleryFilterType) {
16 | findGalleries(filter: $filter, gallery_filter: $gallery_filter) {
17 | count
18 | }
19 | }
20 |
21 | query FindGalleryPerformers($id: ID!) {
22 | findGallery(id: $id) {
23 | id
24 | performers {
25 | ...PerformerData
26 | }
27 | }
28 | }
29 |
30 | query FindGalleryTags($id: ID!) {
31 | findGallery(id: $id) {
32 | id
33 | tags {
34 | ...TagData
35 | }
36 | }
37 | }
38 |
39 | fragment GalleryData on Gallery {
40 | id
41 | title
42 | image_count
43 | date
44 | details
45 | rating100
46 | code
47 | photographer
48 | created_at
49 | updated_at
50 | files {
51 | path
52 | }
53 | folder {
54 | path
55 | }
56 | performers {
57 | ...SlimPerformerData
58 | }
59 | tags {
60 | ...SlimTagData
61 | }
62 | studio {
63 | id
64 | name
65 | image_path
66 | }
67 | scenes {
68 | id
69 | }
70 | paths {
71 | cover
72 | preview
73 | }
74 | }
75 |
76 | mutation UpdateGallery($input: GalleryUpdateInput!) {
77 | galleryUpdate(input: $input) {
78 | ...GalleryData
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/app/src/main/graphql/FindImages.graphql:
--------------------------------------------------------------------------------
1 | query FindImages($filter: FindFilterType, $image_filter: ImageFilterType, $ids: [ID!]) {
2 | findImages(filter: $filter, image_filter: $image_filter, ids: $ids) {
3 | images {
4 | ...ImageData
5 | }
6 | }
7 | }
8 |
9 | query CountImages($filter: FindFilterType, $image_filter: ImageFilterType) {
10 | findImages(filter: $filter, image_filter: $image_filter) {
11 | count
12 | }
13 | }
14 |
15 | query FindImage($id: ID) {
16 | findImage(id: $id) {
17 | ...ImageData
18 | }
19 | }
20 |
21 | query GetExtraImage($id: ID) {
22 | findImage(id: $id) {
23 | ...ExtraImageData
24 | }
25 | }
26 |
27 | fragment ImageData on Image {
28 | id
29 | title
30 | code
31 | rating100
32 | date
33 | details
34 | photographer
35 | o_counter
36 | created_at
37 | updated_at
38 | paths {
39 | thumbnail
40 | preview
41 | image
42 | }
43 | performers{
44 | id
45 | }
46 | studio {
47 | id
48 | name
49 | }
50 | tags {
51 | id
52 | }
53 | galleries {
54 | id
55 | }
56 | visual_files {
57 | ... on BaseFile{
58 | id
59 | path
60 | size
61 | __typename
62 | }
63 | ... on ImageFile {
64 | width
65 | height
66 |
67 | }
68 | ... on VideoFile{
69 | width
70 | height
71 | format
72 | video_codec
73 | audio_codec
74 | duration
75 | }
76 | }
77 | }
78 |
79 | fragment ExtraImageData on Image {
80 | id
81 | performers{
82 | ...PerformerData
83 | }
84 | tags {
85 | ...TagData
86 | }
87 | galleries {
88 | ...GalleryData
89 | }
90 | studio {
91 | ...StudioData
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/app/src/main/graphql/FindSavedFilter.graphql:
--------------------------------------------------------------------------------
1 | query FindSavedFilter($id: ID!) {
2 | findSavedFilter(id: $id) {
3 | ...SavedFilter
4 | __typename
5 | }
6 | }
7 |
8 | query FindSavedFilters($mode: FilterMode!) {
9 | findSavedFilters(mode: $mode) {
10 | ...SavedFilter
11 | __typename
12 | }
13 | }
14 |
15 | fragment SavedFilter on SavedFilter {
16 | id
17 | mode
18 | name
19 | find_filter {
20 | q
21 | page
22 | per_page
23 | sort
24 | direction
25 | __typename
26 | }
27 | object_filter
28 | ui_options
29 | __typename
30 | }
31 |
32 | mutation SaveFilter($input: SaveFilterInput!) {
33 | saveFilter(input: $input) {
34 | ...SavedFilter
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/graphql/FindStudios.graphql:
--------------------------------------------------------------------------------
1 | query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType, $ids: [ID!]) {
2 | findStudios(filter: $filter, studio_filter: $studio_filter, ids: $ids) {
3 | studios {
4 | ...StudioData
5 | __typename
6 | }
7 | __typename
8 | }
9 | }
10 |
11 | query GetStudio($id: ID!) {
12 | findStudio(id: $id) {
13 | ...StudioData
14 | }
15 | }
16 |
17 | query CountStudios($filter: FindFilterType, $studio_filter: StudioFilterType) {
18 | findStudios(filter: $filter, studio_filter: $studio_filter) {
19 | count
20 | }
21 | }
22 |
23 | query FindStudioTags($id: ID!) {
24 | findStudio(id: $id) {
25 | id
26 | tags {
27 | ...TagData
28 | }
29 | }
30 | }
31 |
32 | fragment StudioData on Studio {
33 | id
34 | name
35 | url
36 | parent_studio {
37 | id
38 | name
39 | url
40 | image_path
41 | __typename
42 | }
43 | child_studios {
44 | id
45 | name
46 | image_path
47 | __typename
48 | }
49 | tags {
50 | ...SlimTagData
51 | __typename
52 | }
53 | ignore_auto_tag
54 | image_path
55 | scene_count
56 | # scene_count_all: scene_count(depth: -1)
57 | image_count
58 | # image_count_all: image_count(depth: -1)
59 | gallery_count
60 | # gallery_count_all: gallery_count(depth: -1)
61 | performer_count
62 | # performer_count_all: performer_count(depth: -1)
63 | group_count
64 | # group_count_all: group_count(depth: -1)
65 | details
66 | rating100
67 | aliases
68 | favorite
69 | __typename
70 | created_at
71 | updated_at
72 | }
73 |
74 | mutation UpdateStudio($input: StudioUpdateInput!){
75 | studioUpdate(input: $input){
76 | ...StudioData
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/src/main/graphql/FindTags.graphql:
--------------------------------------------------------------------------------
1 | query FindTags($filter: FindFilterType, $tag_filter: TagFilterType, $ids: [ID!]) {
2 | findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids){
3 | tags {
4 | ...TagData
5 | __typename
6 | }
7 | }
8 | }
9 |
10 | query GetTag($id: ID!) {
11 | findTag(id: $id) {
12 | ...TagData
13 | }
14 | }
15 |
16 | query CountTags($filter: FindFilterType, $tag_filter: TagFilterType) {
17 | findTags(filter: $filter, tag_filter: $tag_filter){
18 | count
19 | }
20 | }
21 |
22 | mutation CreateTag($input: TagCreateInput!) {
23 | tagCreate(input: $input) {
24 | ...TagData
25 | }
26 | }
27 |
28 | mutation UpdateTag($input: TagUpdateInput!){
29 | tagUpdate(input: $input){
30 | ...TagData
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/graphql/Fragments.graphql:
--------------------------------------------------------------------------------
1 | fragment SlimTagData on Tag {
2 | id
3 | name
4 | }
5 |
6 | fragment TagData on Tag {
7 | id
8 | name
9 | description
10 | sort_name
11 | favorite
12 | aliases
13 | scene_count
14 | performer_count
15 | scene_marker_count
16 | image_path
17 | image_count
18 | gallery_count
19 | parent_count
20 | child_count
21 | created_at
22 | updated_at
23 | }
24 |
25 | fragment StashJob on Job {
26 | id
27 | status
28 | progress
29 | description
30 | subTasks
31 | addTime
32 | endTime
33 | error
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/graphql/ImageUpdate.graphql:
--------------------------------------------------------------------------------
1 | mutation UpdateImage($input: ImageUpdateInput!) {
2 | imageUpdate(input: $input) {
3 | id
4 | studio {
5 | ...StudioData
6 | }
7 | tags {
8 | ...TagData
9 | }
10 | performers {
11 | ...PerformerData
12 | }
13 | galleries {
14 | ...GalleryData
15 | }
16 | rating100
17 | }
18 | }
19 |
20 | mutation ImageIncrementO($id: ID!) {
21 | imageIncrementO(id: $id)
22 | }
23 |
24 | mutation ImageDecrementO($id: ID!) {
25 | imageDecrementO(id: $id)
26 | }
27 |
28 | mutation ImageResetO($id: ID!) {
29 | imageResetO(id: $id)
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/graphql/README.md:
--------------------------------------------------------------------------------
1 | # Stash GraphQL
2 |
3 | This directory contains the GraphQL query files.
4 |
5 | ## Queries
6 |
7 | Some of the query files (`*.graphql`) are modified from the queries in https://github.com/stashapp/stash/tree/develop/graphql.
8 |
--------------------------------------------------------------------------------
/app/src/main/graphql/RunPluginTask.graphql:
--------------------------------------------------------------------------------
1 | mutation RunPluginTask($plugin_id: ID!, $task_name: String!, $args_map: Map) {
2 | runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args_map: $args_map)
3 | }
4 |
--------------------------------------------------------------------------------
/app/src/main/graphql/SaveSceneActivity.graphql:
--------------------------------------------------------------------------------
1 | mutation SceneSaveActivity($scene_id: ID!, $resume_time: Float!, $play_duration: Float) {
2 | sceneSaveActivity(id: $scene_id, resume_time: $resume_time, playDuration: $play_duration)
3 | }
4 |
5 | mutation SceneResetO($scene_id: ID!) {
6 | sceneResetO(id: $scene_id)
7 | }
8 |
9 | mutation SceneAddO($scene_id: ID!, $times: [Timestamp!]) {
10 | sceneAddO(id: $scene_id, times: $times) {
11 | count
12 | history
13 | }
14 | }
15 |
16 | mutation SceneDeleteO($scene_id: ID!, $times: [Timestamp!]) {
17 | sceneDeleteO(id: $scene_id, times: $times) {
18 | count
19 | history
20 | }
21 | }
22 |
23 | mutation SceneAddPlayCount($scene_id: ID!, $times: [Timestamp!]) {
24 | sceneAddPlay(id: $scene_id, times: $times) {
25 | count
26 | history
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/graphql/SceneUpdate.graphql:
--------------------------------------------------------------------------------
1 | mutation SceneUpdate($input: SceneUpdateInput!) {
2 | sceneUpdate(input: $input) {
3 | id
4 | tags {
5 | ...TagData
6 | }
7 | performers {
8 | ...PerformerData
9 | }
10 | studio {
11 | ...StudioData
12 | }
13 | groups {
14 | scene_index
15 | group{
16 | ...GroupData
17 | }
18 | }
19 | galleries {
20 | ...GalleryData
21 | }
22 | rating100
23 | }
24 | }
25 |
26 | mutation CreateMarker($input: SceneMarkerCreateInput!) {
27 | sceneMarkerCreate(input: $input) {
28 | ...FullMarkerData
29 | }
30 | }
31 |
32 | mutation DeleteMarker($id: ID!) {
33 | sceneMarkerDestroy(id: $id)
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/graphql/ServerInfo.graphql:
--------------------------------------------------------------------------------
1 | query ServerInfo {
2 | version {
3 | version
4 | }
5 | findScenes {
6 | count
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/main/graphql/Subscriptions.graphql:
--------------------------------------------------------------------------------
1 | subscription JobProgress {
2 | jobsSubscribe {
3 | type
4 | job {
5 | ...StashJob
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/main/graphql/SystemStatus.graphql:
--------------------------------------------------------------------------------
1 | query SystemStatus {
2 | systemStatus {
3 | status
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/LicenseFragment.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import android.widget.TextView
6 | import androidx.fragment.app.Fragment
7 | import com.github.damontecres.stashapp.util.concatIfNotBlank
8 | import java.io.BufferedReader
9 |
10 | /**
11 | * Show various licenses for third party libraries included in the app
12 | */
13 | class LicenseFragment : Fragment(R.layout.license) {
14 | override fun onViewCreated(
15 | view: View,
16 | savedInstanceState: Bundle?,
17 | ) {
18 | super.onViewCreated(view, savedInstanceState)
19 |
20 | val attributions = resources.getTextArray(R.array.stash_license_attributions)
21 | val licenseText =
22 | resources.assets
23 | .open("LICENSE")
24 | .bufferedReader()
25 | .use(BufferedReader::readText)
26 | val otherLicenses =
27 | resources.assets
28 | .list("licenses")!!
29 | .map {
30 | resources.assets
31 | .open("licenses/$it")
32 | .bufferedReader()
33 | .use(BufferedReader::readText)
34 | }.toTypedArray()
35 |
36 | val text =
37 | concatIfNotBlank(
38 | getString(R.string.license_separator),
39 | *attributions,
40 | licenseText,
41 | *otherLicenses,
42 | )
43 |
44 | view.findViewById(R.id.license_text).text = text
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/UpdateChangelogFragment.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import android.widget.TextView
6 | import androidx.fragment.app.Fragment
7 | import androidx.lifecycle.lifecycleScope
8 | import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler
9 | import com.github.damontecres.stashapp.util.UpdateChecker
10 | import io.noties.markwon.Markwon
11 | import kotlinx.coroutines.launch
12 |
13 | class UpdateChangelogFragment : Fragment(R.layout.changelog) {
14 | override fun onViewCreated(
15 | view: View,
16 | savedInstanceState: Bundle?,
17 | ) {
18 | super.onViewCreated(view, savedInstanceState)
19 | viewLifecycleOwner.lifecycleScope.launch(StashCoroutineExceptionHandler(autoToast = true)) {
20 | val release = UpdateChecker.getLatestRelease(requireActivity())
21 | if (release != null) {
22 | val changelogText =
23 | "# ${release.version}\n\n${release.body}"
24 | // Convert PR urls to number
25 | .replace(
26 | Regex("https://github.com/damontecres/StashAppAndroidTV/pull/(\\d+)"),
27 | "#$1",
28 | )
29 | // Remove the last line for full changelog since its just a link
30 | .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
31 | val textView = view.findViewById(R.id.changelog_text)
32 | val markdown = Markwon.create(requireContext())
33 | markdown.setMarkdown(textView, changelogText)
34 | }
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/actions/CreateMarkerAction.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.actions
2 |
3 | data class CreateMarkerAction(
4 | val position: Long,
5 | ) {
6 | // This is kind of hacky
7 | val id get() = StashAction.CREATE_MARKER.id
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/actions/StashAction.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.actions
2 |
3 | /**
4 | * Represents an action a user can take
5 | *
6 | * This is sort of a catch-all, but [com.github.damontecres.stashapp.presenters.ActionPresenter] can render them
7 | */
8 | enum class StashAction(
9 | val id: Long,
10 | val actionName: String,
11 | ) {
12 | ADD_TAG(1L, "Add Tag"),
13 | ADD_PERFORMER(2L, "Add Performer"),
14 | FORCE_TRANSCODE(3L, "Play with Transcoding"),
15 | CREATE_MARKER(4L, "Create Marker"),
16 | FORCE_DIRECT_PLAY(5L, "Play directly"),
17 | CREATE_NEW(6L, "Create new"),
18 | SET_STUDIO(7L, "Set Studio"),
19 | SHIFT_MARKERS(8L, "Change marker timestamp"),
20 | ADD_GALLERY(9L, "Add Gallery"),
21 | ADD_GROUP(10L, "Add Group"),
22 | ;
23 |
24 | companion object {
25 | // Actions that require searching for something
26 | val SEARCH_FOR_ACTIONS =
27 | setOf(ADD_TAG, ADD_PERFORMER, CREATE_MARKER, SET_STUDIO, ADD_GALLERY, ADD_GROUP)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/actions/StashActionClickedListener.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.actions
2 |
3 | import com.github.damontecres.stashapp.data.OCounter
4 |
5 | /**
6 | * Respond to clicks on a card for a [StashAction]
7 | */
8 | interface StashActionClickedListener {
9 | fun onClicked(action: StashAction)
10 |
11 | fun incrementOCounter(counter: OCounter)
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/api/fragment/GroupRelationshipData.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.api.fragment
2 |
3 | enum class GroupRelationshipType {
4 | CONTAINING,
5 | SUB,
6 | }
7 |
8 | data class GroupRelationshipData(
9 | val group: GroupData,
10 | val type: GroupRelationshipType,
11 | val description: String?,
12 | ) : StashData {
13 | override val id: String = group.id
14 | }
15 |
16 | fun GroupDescriptionData.toRelationship(type: GroupRelationshipType): GroupRelationshipData =
17 | GroupRelationshipData(
18 | group.groupData,
19 | type,
20 | description,
21 | )
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/api/fragment/StashData.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.api.fragment
2 |
3 | sealed interface StashData {
4 | val id: String
5 |
6 | override fun equals(other: Any?): Boolean
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/JobResult.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data
2 |
3 | sealed class JobResult {
4 | data object NotFound : JobResult()
5 |
6 | data class Failure(
7 | val message: String?,
8 | ) : JobResult()
9 |
10 | data object Success : JobResult()
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/OCounter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data
2 |
3 | data class OCounter(
4 | val id: String,
5 | val count: Int,
6 | )
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/PlaylistItem.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data
2 |
3 | import com.github.damontecres.stashapp.api.fragment.MarkerData
4 | import com.github.damontecres.stashapp.api.fragment.SlimSceneData
5 | import com.github.damontecres.stashapp.util.joinNotNullOrBlank
6 | import com.github.damontecres.stashapp.util.titleOrFilename
7 | import kotlin.time.DurationUnit
8 | import kotlin.time.toDuration
9 |
10 | /**
11 | * Represents an item in a playlist/queue
12 | */
13 | data class PlaylistItem(
14 | val index: Int,
15 | val title: CharSequence?,
16 | val subtitle: CharSequence?,
17 | val details1: CharSequence?,
18 | val details2: CharSequence?,
19 | val imageUrl: String?,
20 | )
21 |
22 | fun MarkerData.toPlayListItem(index: Int): PlaylistItem {
23 | val name =
24 | title.ifBlank {
25 | primary_tag.slimTagData.name
26 | }
27 | val details =
28 | buildList {
29 | add(primary_tag.slimTagData.name)
30 | addAll(tags.map { it.slimTagData.name })
31 | }.joinNotNullOrBlank(", ")
32 | return PlaylistItem(
33 | index,
34 | title = "$name - ${seconds.toInt().toDuration(DurationUnit.SECONDS)}",
35 | subtitle = scene.minimalSceneData.titleOrFilename,
36 | details1 = details,
37 | details2 = scene.minimalSceneData.date,
38 | imageUrl = screenshot,
39 | )
40 | }
41 |
42 | fun SlimSceneData.toPlayListItem(index: Int): PlaylistItem =
43 | PlaylistItem(
44 | index,
45 | title = titleOrFilename,
46 | subtitle = studio?.name,
47 | details1 = performers.map { it.name }.joinNotNullOrBlank(", "),
48 | details2 = date,
49 | imageUrl = paths.screenshot,
50 | )
51 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/room/AppDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data.room
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import androidx.room.TypeConverters
6 |
7 | @Database(entities = [RecentSearchItem::class, PlaybackEffect::class], version = 5)
8 | @TypeConverters(Converters::class)
9 | abstract class AppDatabase : RoomDatabase() {
10 | abstract fun recentSearchItemsDao(): RecentSearchItemsDao
11 |
12 | abstract fun playbackEffectsDao(): PlaybackEffectsDao
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/room/Converters.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data.room
2 |
3 | import androidx.room.TypeConverter
4 | import com.github.damontecres.stashapp.data.DataType
5 | import java.util.Date
6 |
7 | class Converters {
8 | @TypeConverter
9 | fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }
10 |
11 | @TypeConverter
12 | fun dateToTimestamp(date: Date?): Long? = date?.time
13 |
14 | @TypeConverter
15 | fun fromDataType(value: Int?): DataType? = value?.let { DataType.entries[it] }
16 |
17 | @TypeConverter
18 | fun dataTypeToInt(value: DataType?): Int? = value?.ordinal
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/room/Migrations.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data.room
2 |
3 | import androidx.room.migration.Migration
4 | import androidx.sqlite.db.SupportSQLiteDatabase
5 | import com.github.damontecres.stashapp.data.DataType
6 |
7 | val MIGRATION_4_TO_5 =
8 | object : Migration(4, 5) {
9 | override fun migrate(db: SupportSQLiteDatabase) {
10 | // Create a new temporary table with the new primary key
11 | db.execSQL(
12 | "CREATE TABLE IF NOT EXISTS `playback_effects_temp` " +
13 | "(`serverUrl` TEXT NOT NULL, `id` TEXT NOT NULL, `dataType` INTEGER NOT NULL, " +
14 | "`rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, " +
15 | "`saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, " +
16 | "`red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, " +
17 | "`blur` INTEGER NOT NULL, PRIMARY KEY(`serverUrl`, `id`, `dataType`))",
18 | )
19 | // Insert all of the data into the new temp table, defaulting the dataType to SCENE
20 | db.execSQL(
21 | "INSERT INTO playback_effects_temp " +
22 | "SELECT serverUrl, id, ${DataType.SCENE.ordinal}, rotation, brightness, contrast, " +
23 | "saturation, hue, red, green, blue, blur " +
24 | "FROM playback_effects",
25 | )
26 | // Drop original table and rename temporary one
27 | db.execSQL("DROP TABLE playback_effects")
28 | db.execSQL("ALTER TABLE playback_effects_temp RENAME TO playback_effects")
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/room/PlaybackEffect.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data.room
2 |
3 | import androidx.room.Embedded
4 | import androidx.room.Entity
5 | import com.github.damontecres.stashapp.data.DataType
6 | import com.github.damontecres.stashapp.data.VideoFilter
7 |
8 | /**
9 | * Store a [VideoFilter] for a specific scene (by server & scene ID)
10 | */
11 | @Entity(tableName = "playback_effects", primaryKeys = ["serverUrl", "id", "dataType"])
12 | data class PlaybackEffect(
13 | val serverUrl: String,
14 | val id: String,
15 | val dataType: DataType,
16 | @Embedded val videoFilter: VideoFilter,
17 | )
18 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/room/PlaybackEffectsDao.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data.room
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import com.github.damontecres.stashapp.data.DataType
8 |
9 | /**
10 | * Store [PlaybackEffect]/][VideoFilter]s
11 | */
12 | @Dao
13 | interface PlaybackEffectsDao {
14 | @Query("SELECT * FROM playback_effects WHERE serverUrl = :serverUrl AND id = :id AND dataType = :dataType")
15 | fun getPlaybackEffect(
16 | serverUrl: String,
17 | id: String,
18 | dataType: DataType,
19 | ): PlaybackEffect?
20 |
21 | @Query("SELECT * FROM playback_effects WHERE serverUrl = :serverUrl")
22 | fun getPlaybackEffects(serverUrl: String): List
23 |
24 | @Insert(onConflict = OnConflictStrategy.REPLACE)
25 | fun insert(vararg items: PlaybackEffect)
26 |
27 | @Query("DELETE FROM playback_effects")
28 | fun deleteAll()
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/room/RecentSearchItem.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data.room
2 |
3 | import androidx.room.Entity
4 | import com.github.damontecres.stashapp.data.DataType
5 | import java.util.Date
6 |
7 | /**
8 | * Represents an item that the user recently used in a search-for task
9 | *
10 | * Must be identified by server, data type, & ID
11 | */
12 | @Entity(tableName = "recent_search_items", primaryKeys = ["serverUrl", "id", "dataType"])
13 | data class RecentSearchItem(
14 | val serverUrl: String,
15 | val id: String,
16 | val dataType: DataType,
17 | val timestamp: Date = Date(),
18 | )
19 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/data/room/RecentSearchItemsDao.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.data.room
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Delete
5 | import androidx.room.Insert
6 | import androidx.room.OnConflictStrategy
7 | import androidx.room.Query
8 | import com.github.damontecres.stashapp.data.DataType
9 |
10 | /**
11 | * Retrieve and store [RecentSearchItem]s
12 | */
13 | @Dao
14 | interface RecentSearchItemsDao {
15 | @Query("SELECT * FROM recent_search_items WHERE serverUrl = :serverUrl AND dataType = :dataType ORDER BY timestamp DESC LIMIT :count")
16 | fun getMostRecent(
17 | count: Int = 25,
18 | serverUrl: String,
19 | dataType: DataType,
20 | ): List
21 |
22 | @Insert(onConflict = OnConflictStrategy.REPLACE)
23 | fun insert(vararg items: RecentSearchItem)
24 |
25 | @Delete
26 | fun delete(vararg items: RecentSearchItem)
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterFragment.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.filter
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import androidx.fragment.app.Fragment
6 | import androidx.fragment.app.activityViewModels
7 | import androidx.leanback.app.GuidedStepSupportFragment
8 | import com.github.damontecres.stashapp.R
9 | import com.github.damontecres.stashapp.navigation.Destination
10 | import com.github.damontecres.stashapp.util.getDestination
11 |
12 | class CreateFilterFragment : Fragment(R.layout.frame) {
13 | private val viewModel by activityViewModels()
14 |
15 | override fun onCreate(savedInstanceState: Bundle?) {
16 | super.onCreate(savedInstanceState)
17 |
18 | val dest = requireArguments().getDestination()
19 | val startingFilter = dest.startingFilter
20 | viewModel.initialize(
21 | dest.dataType,
22 | startingFilter,
23 | )
24 | }
25 |
26 | override fun onViewCreated(
27 | view: View,
28 | savedInstanceState: Bundle?,
29 | ) {
30 | super.onViewCreated(view, savedInstanceState)
31 | viewModel.ready.observe(viewLifecycleOwner) { ready ->
32 | if (ready) {
33 | GuidedStepSupportFragment.add(
34 | childFragmentManager,
35 | CreateFilterStep(),
36 | R.id.frame_content,
37 | )
38 | }
39 | }
40 | }
41 |
42 | companion object {
43 | private const val TAG = "CreateFilterFragment"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/filter/picker/FloatPickerFragment.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.filter.picker
2 |
3 | import android.os.Bundle
4 | import android.text.InputType
5 | import androidx.leanback.widget.GuidedAction
6 | import com.apollographql.apollo.api.Optional
7 | import com.github.damontecres.stashapp.api.type.CriterionModifier
8 | import com.github.damontecres.stashapp.api.type.FloatCriterionInput
9 | import com.github.damontecres.stashapp.api.type.StashDataFilter
10 | import com.github.damontecres.stashapp.filter.FilterOption
11 |
12 | /**
13 | * Pick a decimal/float value
14 | */
15 | class FloatPickerFragment(
16 | filterOption: FilterOption,
17 | ) : TwoValuePicker(filterOption) {
18 | override val valueInputType: Int
19 | get() = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
20 |
21 | override fun parseValue(v: String?): Double? = v?.toDoubleOrNull()
22 |
23 | override fun createCriterionInput(
24 | value1: Double?,
25 | value2: Double?,
26 | modifier: CriterionModifier,
27 | ): FloatCriterionInput? =
28 | if (value1 != null) {
29 | FloatCriterionInput(
30 | value = value1,
31 | value2 = Optional.presentIfNotNull(value2),
32 | modifier = modifier,
33 | )
34 | } else if (modifier == CriterionModifier.IS_NULL || modifier == CriterionModifier.NOT_NULL) {
35 | FloatCriterionInput(value = 0.0, modifier = modifier)
36 | } else {
37 | null
38 | }
39 |
40 | override fun onCreateActions(
41 | actions: MutableList,
42 | savedInstanceState: Bundle?,
43 | ) {
44 | val curVal = filterOption.getter(viewModel.objectFilter.value!!).getOrNull()
45 | value1 = curVal?.value
46 | value2 = curVal?.value2?.getOrNull()
47 | modifier = curVal?.modifier ?: CriterionModifier.EQUALS
48 | createActionList(actions)
49 | }
50 |
51 | companion object {
52 | private const val TAG = "FloatPickerFragment"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/filter/picker/GuidedDurationPickerAction.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.filter.picker
2 |
3 | import android.content.Context
4 | import android.os.Bundle
5 | import androidx.leanback.widget.GuidedAction
6 |
7 | /**
8 | * [GuidedAction] for picking a duration
9 | */
10 | class GuidedDurationPickerAction : GuidedAction() {
11 | class Builder(
12 | context: Context,
13 | ) : BuilderBase(context) {
14 | private var duration = 0
15 |
16 | init {
17 | hasEditableActivatorView(true)
18 | }
19 |
20 | fun duration(duration: Int): Builder {
21 | this.duration = duration
22 | return this
23 | }
24 |
25 | fun build(): GuidedDurationPickerAction {
26 | val action = GuidedDurationPickerAction()
27 | action.duration = duration
28 | super.applyValues(action)
29 | return action
30 | }
31 | }
32 |
33 | var duration: Int = 0
34 |
35 | override fun onSaveInstanceState(
36 | bundle: Bundle,
37 | key: String,
38 | ) {
39 | bundle.putInt(key, duration)
40 | }
41 |
42 | override fun onRestoreInstanceState(
43 | bundle: Bundle,
44 | key: String,
45 | ) {
46 | duration = bundle.getInt(key)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/filter/picker/IntPickerFragment.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.filter.picker
2 |
3 | import android.os.Bundle
4 | import android.text.InputType
5 | import androidx.leanback.widget.GuidedAction
6 | import com.apollographql.apollo.api.Optional
7 | import com.github.damontecres.stashapp.api.type.CriterionModifier
8 | import com.github.damontecres.stashapp.api.type.IntCriterionInput
9 | import com.github.damontecres.stashapp.api.type.StashDataFilter
10 | import com.github.damontecres.stashapp.filter.FilterOption
11 |
12 | class IntPickerFragment(
13 | filterOption: FilterOption,
14 | ) : TwoValuePicker(filterOption) {
15 | override val valueInputType: Int
16 | get() = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED
17 |
18 | override fun parseValue(v: String?): Int? = v?.toIntOrNull()
19 |
20 | override fun createCriterionInput(
21 | value1: Int?,
22 | value2: Int?,
23 | modifier: CriterionModifier,
24 | ): IntCriterionInput? =
25 | if (value1 != null) {
26 | IntCriterionInput(
27 | value = value1,
28 | value2 = Optional.presentIfNotNull(value2),
29 | modifier = modifier,
30 | )
31 | } else if (modifier == CriterionModifier.IS_NULL || modifier == CriterionModifier.NOT_NULL) {
32 | IntCriterionInput(value = 0, modifier = modifier)
33 | } else {
34 | null
35 | }
36 |
37 | override fun onCreateActions(
38 | actions: MutableList,
39 | savedInstanceState: Bundle?,
40 | ) {
41 | val curVal = filterOption.getter(viewModel.objectFilter.value!!).getOrNull()
42 | value1 = curVal?.value
43 | value2 = curVal?.value2?.getOrNull()
44 | modifier = curVal?.modifier ?: CriterionModifier.EQUALS
45 | createActionList(actions)
46 | }
47 |
48 | companion object {
49 | private const val TAG = "IntPickerFragment"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/image/ImageController.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.image
2 |
3 | /**
4 | * Interface to expose making modifications to an image
5 | */
6 | interface ImageController {
7 | fun zoomIn()
8 |
9 | fun zoomOut()
10 |
11 | fun rotateLeft()
12 |
13 | fun rotateRight()
14 |
15 | fun flip()
16 |
17 | fun reset(animate: Boolean = true)
18 |
19 | fun isImageZoomedIn(): Boolean
20 | }
21 |
22 | /**
23 | * Interface to expose controls for a video
24 | */
25 | interface VideoController {
26 | fun play()
27 |
28 | fun pause()
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/navigation/FilterAndPosition.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.navigation
2 |
3 | import com.github.damontecres.stashapp.suppliers.FilterArgs
4 |
5 | data class FilterAndPosition(
6 | val filter: FilterArgs,
7 | val position: Int,
8 | )
9 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/playback/ControllerVisibilityListenerList.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.playback
2 |
3 | import androidx.media3.ui.PlayerView
4 |
5 | /**
6 | * Since a [PlayerView] can only have one [PlayerView.ControllerVisibilityListener], this class allows for multiple by maintaining a list of them
7 | */
8 | class ControllerVisibilityListenerList : PlayerView.ControllerVisibilityListener {
9 | private val listeners = mutableListOf()
10 |
11 | fun addListener(listener: PlayerView.ControllerVisibilityListener) {
12 | listeners.add(listener)
13 | }
14 |
15 | override fun onVisibilityChanged(visibility: Int) {
16 | listeners.forEach { it.onVisibilityChanged(visibility) }
17 | }
18 |
19 | fun removeAllListeners() {
20 | listeners.clear()
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/playback/PlaylistScenesFragment.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.playback
2 |
3 | import androidx.annotation.OptIn
4 | import androidx.media3.common.MediaItem
5 | import androidx.media3.common.util.UnstableApi
6 | import com.github.damontecres.stashapp.api.CountScenesQuery
7 | import com.github.damontecres.stashapp.api.FindVideoScenesQuery
8 | import com.github.damontecres.stashapp.api.fragment.SlimSceneData
9 | import com.github.damontecres.stashapp.api.fragment.VideoSceneData
10 | import com.github.damontecres.stashapp.data.DataType
11 | import com.github.damontecres.stashapp.data.Scene
12 |
13 | /**
14 | * A [PlaylistFragment] that plays [SlimSceneData] videos
15 | */
16 | @OptIn(UnstableApi::class)
17 | class PlaylistScenesFragment : PlaylistFragment() {
18 | override val previewsEnabled: Boolean
19 | get() = true
20 |
21 | override val optionsButtonOptions: OptionsButtonOptions
22 | get() = OptionsButtonOptions(DataType.SCENE, true)
23 |
24 | override val activityTrackingEnabled: Boolean
25 | get() = true
26 |
27 | override fun builderCallback(item: VideoSceneData): (MediaItem.Builder.() -> Unit)? = null
28 |
29 | override fun convertToScene(item: VideoSceneData): Scene = Scene.fromVideoSceneData(item)
30 |
31 | companion object {
32 | private const val TAG = "PlaylistScenesFragment"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/ActionPresenter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import com.github.damontecres.stashapp.actions.StashAction
4 |
5 | class ActionPresenter(
6 | callback: LongClickCallBack? = null,
7 | ) : StashPresenter(callback) {
8 | override fun doOnBindViewHolder(
9 | cardView: StashImageCardView,
10 | item: StashAction,
11 | ) {
12 | cardView.titleText = item.actionName
13 | cardView.setMainImageDimensions(CARD_WIDTH, CARD_HEIGHT)
14 | }
15 |
16 | companion object {
17 | private const val TAG = "ActionPresenter"
18 |
19 | const val CARD_WIDTH = 235
20 | const val CARD_HEIGHT = 160
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/CreateMarkerActionPresenter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import com.github.damontecres.stashapp.actions.CreateMarkerAction
4 | import com.github.damontecres.stashapp.views.durationToString
5 |
6 | class CreateMarkerActionPresenter(
7 | callback: LongClickCallBack? = null,
8 | ) : StashPresenter(callback) {
9 | override fun doOnBindViewHolder(
10 | cardView: StashImageCardView,
11 | item: CreateMarkerAction,
12 | ) {
13 | cardView.titleText = "Create Marker"
14 | cardView.contentText = durationToString(item.position / 1000.0)
15 | cardView.setMainImageDimensions(
16 | ActionPresenter.CARD_WIDTH,
17 | ActionPresenter.CARD_HEIGHT,
18 | )
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/GalleryPresenter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import com.github.damontecres.stashapp.R
4 | import com.github.damontecres.stashapp.api.fragment.GalleryData
5 | import com.github.damontecres.stashapp.data.DataType
6 | import com.github.damontecres.stashapp.util.concatIfNotBlank
7 | import com.github.damontecres.stashapp.util.name
8 | import java.util.EnumMap
9 |
10 | class GalleryPresenter(
11 | callback: LongClickCallBack? = null,
12 | ) : StashPresenter(callback) {
13 | override fun doOnBindViewHolder(
14 | cardView: StashImageCardView,
15 | item: GalleryData,
16 | ) {
17 | cardView.blackImageBackground = false
18 | cardView.titleText = item.name
19 |
20 | val details = mutableListOf()
21 | details.add(item.studio?.name)
22 | details.add(item.date)
23 | cardView.contentText = concatIfNotBlank(" - ", details)
24 |
25 | val dataTypeMap = EnumMap(DataType::class.java)
26 | dataTypeMap[DataType.TAG] = item.tags.size
27 | dataTypeMap[DataType.PERFORMER] = item.performers.size
28 | dataTypeMap[DataType.SCENE] = item.scenes.size
29 | dataTypeMap[DataType.IMAGE] = item.image_count
30 |
31 | cardView.setUpExtraRow(dataTypeMap, null)
32 |
33 | cardView.setMainImageDimensions(CARD_WIDTH, CARD_HEIGHT)
34 | val coverImage = item.paths.cover
35 | loadImage(cardView, coverImage, defaultDrawable = R.drawable.default_gallery)
36 |
37 | cardView.setRating100(item.rating100)
38 | }
39 |
40 | override fun imageMatchParent(item: GalleryData): Boolean = item.paths.cover.isBlank() || item.paths.cover.isDefaultUrl
41 |
42 | companion object {
43 | private const val TAG = "GalleryPresenter"
44 |
45 | const val CARD_WIDTH = ImagePresenter.CARD_WIDTH
46 | const val CARD_HEIGHT = ImagePresenter.CARD_HEIGHT
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/GroupRelationshipPresenter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import com.github.damontecres.stashapp.api.fragment.GroupRelationshipData
4 |
5 | class GroupRelationshipPresenter(
6 | callback: LongClickCallBack? = null,
7 | ) : StashPresenter(
8 | callback,
9 | ) {
10 | private val groupPresenter = GroupPresenter()
11 |
12 | override fun doOnBindViewHolder(
13 | cardView: StashImageCardView,
14 | item: GroupRelationshipData,
15 | ) {
16 | groupPresenter.doOnBindViewHolder(cardView, item.group)
17 | cardView.contentText = item.description
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/NullPresenter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import com.github.damontecres.stashapp.data.DataType
4 | import com.github.damontecres.stashapp.util.defaultCardHeight
5 | import com.github.damontecres.stashapp.util.defaultCardWidth
6 |
7 | /**
8 | * A no-op implementation of [StashPresenter] that accepts a null item
9 | */
10 | class NullPresenter(
11 | private val width: Int,
12 | private val height: Int,
13 | ) : StashPresenter() {
14 | constructor(dataType: DataType) : this(dataType.defaultCardWidth, dataType.defaultCardHeight)
15 |
16 | override fun doOnBindViewHolder(
17 | cardView: StashImageCardView,
18 | item: Any?,
19 | ) {
20 | // no-op
21 | }
22 |
23 | fun bindNull(cardView: StashImageCardView) {
24 | cardView.setMainImageDimensions(width, height)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/NullPresenterSelector.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import androidx.leanback.widget.Presenter
4 | import androidx.leanback.widget.PresenterSelector
5 |
6 | /**
7 | * A [PresenterSelector] that delegates to another [PresenterSelector] except when the item is null, it return the specified [Presenter]
8 | */
9 | class NullPresenterSelector(
10 | private val presenterSelector: PresenterSelector,
11 | private val nullPresenter: Presenter,
12 | ) : PresenterSelector() {
13 | override fun getPresenter(item: Any?): Presenter =
14 | if (item == null) {
15 | nullPresenter
16 | } else {
17 | presenterSelector.getPresenter(item)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/OCounterPresenter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import com.github.damontecres.stashapp.R
4 | import com.github.damontecres.stashapp.data.OCounter
5 |
6 | class OCounterPresenter(
7 | callback: LongClickCallBack? = null,
8 | ) : StashPresenter(callback) {
9 | override fun doOnBindViewHolder(
10 | cardView: StashImageCardView,
11 | item: OCounter,
12 | ) {
13 | cardView.blackImageBackground = false
14 |
15 | val text = cardView.context.getString(R.string.stashapp_o_counter)
16 | cardView.titleText = "$text (${item.count})"
17 | cardView.setMainImageDimensions(ActionPresenter.CARD_WIDTH, ActionPresenter.CARD_HEIGHT)
18 | cardView.mainImageView.setPadding(
19 | IMAGE_PADDING,
20 | IMAGE_PADDING,
21 | IMAGE_PADDING,
22 | IMAGE_PADDING,
23 | )
24 | loadImage(cardView, R.drawable.sweat_drops)
25 | }
26 |
27 | companion object {
28 | const val IMAGE_PADDING = (ActionPresenter.CARD_WIDTH * .07).toInt()
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/PerformerInScenePresenter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import android.os.Build
4 | import com.github.damontecres.stashapp.R
5 | import com.github.damontecres.stashapp.api.fragment.PerformerData
6 | import java.time.LocalDate
7 | import java.time.Period
8 | import java.time.format.DateTimeFormatter
9 |
10 | /**
11 | * A [PerformerPresenter] which will use the age of a [PerformerData] at specified date for the content text
12 | */
13 | class PerformerInScenePresenter(
14 | private val date: String?,
15 | callback: LongClickCallBack? = null,
16 | ) : PerformerPresenter(
17 | callback,
18 | ) {
19 | override fun getContentText(
20 | cardView: StashImageCardView,
21 | item: PerformerData,
22 | ): CharSequence? =
23 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
24 | val ageInScene =
25 | if (item.birthdate != null && date != null) {
26 | Period
27 | .between(
28 | LocalDate.parse(item.birthdate, DateTimeFormatter.ISO_LOCAL_DATE),
29 | LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE),
30 | ).years
31 | } else {
32 | null
33 | }
34 | if (ageInScene != null) {
35 | val yearsOld = cardView.context.getString(R.string.stashapp_years_old)
36 | val yearsOldStr =
37 | cardView.context.getString(
38 | R.string.stashapp_media_info_performer_card_age_context,
39 | ageInScene.toString(),
40 | yearsOld,
41 | )
42 | yearsOldStr
43 | } else {
44 | super.getContentText(cardView, item)
45 | }
46 | } else {
47 | super.getContentText(cardView, item)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/presenters/PopupOnLongClickListener.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.presenters
2 |
3 | import android.view.View
4 | import android.view.View.OnLongClickListener
5 | import android.widget.AdapterView
6 | import android.widget.ArrayAdapter
7 | import androidx.appcompat.widget.ListPopupWindow
8 | import com.github.damontecres.stashapp.R
9 | import com.github.damontecres.stashapp.util.getMaxMeasuredWidth
10 |
11 | /**
12 | * An OnLongClickListener which shows a popup of predefined options
13 | */
14 | class PopupOnLongClickListener(
15 | private val popupOptions: List,
16 | private val popUpWidth: Int = ListPopupWindow.WRAP_CONTENT,
17 | private val popupItemClickListener: AdapterView.OnItemClickListener,
18 | ) : OnLongClickListener {
19 | override fun onLongClick(view: View): Boolean {
20 | if (popupOptions.isEmpty()) {
21 | return true
22 | }
23 | val listPopUp =
24 | ListPopupWindow(
25 | view.context,
26 | null,
27 | android.R.attr.listPopupWindowStyle,
28 | )
29 | val adapter =
30 | ArrayAdapter(
31 | view.context,
32 | R.layout.popup_item,
33 | popupOptions,
34 | )
35 |
36 | listPopUp.inputMethodMode = ListPopupWindow.INPUT_METHOD_NEEDED
37 | listPopUp.anchorView = view
38 | listPopUp.width =
39 | if (popUpWidth == ListPopupWindow.WRAP_CONTENT) {
40 | getMaxMeasuredWidth(view.context, adapter)
41 | } else {
42 | popUpWidth
43 | }
44 | listPopUp.isModal = true
45 |
46 | listPopUp.setAdapter(adapter)
47 |
48 | listPopUp.setOnItemClickListener { parent: AdapterView<*>, v: View, position: Int, id: Long ->
49 | popupItemClickListener.onItemClick(parent, v, position, id)
50 | listPopUp.dismiss()
51 | }
52 |
53 | listPopUp.show()
54 |
55 | return true
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/setup/SetupFragment.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.setup
2 |
3 | import android.os.Bundle
4 | import androidx.core.content.ContextCompat
5 | import androidx.leanback.widget.GuidanceStylist
6 | import androidx.leanback.widget.GuidedAction
7 | import com.github.damontecres.stashapp.R
8 |
9 | class SetupFragment : SetupGuidedStepSupportFragment() {
10 | override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance =
11 | GuidanceStylist.Guidance(
12 | getString(R.string.app_name_long),
13 | getString(R.string.first_time_setup),
14 | null,
15 | ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
16 | )
17 |
18 | override fun onCreateActions(
19 | actions: MutableList,
20 | savedInstanceState: Bundle?,
21 | ) {
22 | actions.add(
23 | GuidedAction
24 | .Builder(requireContext())
25 | .id(GuidedAction.ACTION_ID_CONTINUE)
26 | .hasNext(true)
27 | .title(R.string.stashapp_actions_continue)
28 | .build(),
29 | )
30 | }
31 |
32 | override fun onGuidedActionClicked(action: GuidedAction) {
33 | nextStep(SetupStep1ServerUrl())
34 | }
35 |
36 | companion object {
37 | const val ACTION_SERVER_URL = 98L
38 | const val ACTION_SERVER_API_KEY = 99L
39 | const val ACTION_PASSWORD_VISIBLE = 100L
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/setup/SetupState.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.setup
2 |
3 | import com.github.damontecres.stashapp.util.StashServer
4 |
5 | data class SetupState(
6 | val serverUrl: String,
7 | val apiKey: String?,
8 | val trustCerts: Boolean = false,
9 | val pinCode: String? = null,
10 | ) {
11 | constructor(serverUrl: CharSequence) : this(serverUrl.toString(), null)
12 |
13 | val stashServer get() = StashServer(serverUrl, apiKey)
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/suppliers/ImageDataSupplier.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.suppliers
2 |
3 | import com.apollographql.apollo.api.Query
4 | import com.github.damontecres.stashapp.api.CountImagesQuery
5 | import com.github.damontecres.stashapp.api.FindImagesQuery
6 | import com.github.damontecres.stashapp.api.fragment.ImageData
7 | import com.github.damontecres.stashapp.api.type.FindFilterType
8 | import com.github.damontecres.stashapp.api.type.ImageFilterType
9 | import com.github.damontecres.stashapp.data.DataType
10 | import com.github.damontecres.stashapp.data.merge
11 |
12 | class ImageDataSupplier(
13 | private val findFilter: FindFilterType?,
14 | private val imageFilter: ImageFilterType?,
15 | ) : StashPagingSource.DataSupplier {
16 | constructor(imageFilter: ImageFilterType? = null) : this(
17 | DataType.IMAGE.asDefaultFindFilterType,
18 | imageFilter,
19 | )
20 |
21 | override val dataType: DataType get() = DataType.TAG
22 |
23 | override fun createQuery(filter: FindFilterType?): Query =
24 | FindImagesQuery(
25 | filter = filter,
26 | image_filter = imageFilter,
27 | ids = null,
28 | )
29 |
30 | override fun getDefaultFilter(): FindFilterType = DataType.IMAGE.asDefaultFindFilterType.merge(findFilter)
31 |
32 | override fun createCountQuery(filter: FindFilterType?): Query = CountImagesQuery(filter, imageFilter)
33 |
34 | override fun parseCountQuery(data: CountImagesQuery.Data): Int = data.findImages.count
35 |
36 | override fun parseQuery(data: FindImagesQuery.Data): List = data.findImages.images.map { it.imageData }
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/suppliers/PerformerDataSupplier.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.suppliers
2 |
3 | import com.apollographql.apollo.api.Query
4 | import com.github.damontecres.stashapp.api.CountPerformersQuery
5 | import com.github.damontecres.stashapp.api.FindPerformersQuery
6 | import com.github.damontecres.stashapp.api.fragment.PerformerData
7 | import com.github.damontecres.stashapp.api.type.FindFilterType
8 | import com.github.damontecres.stashapp.api.type.PerformerFilterType
9 | import com.github.damontecres.stashapp.data.DataType
10 | import com.github.damontecres.stashapp.data.merge
11 |
12 | class PerformerDataSupplier(
13 | private val findFilter: FindFilterType?,
14 | private val performerFilter: PerformerFilterType?,
15 | ) : StashPagingSource.DataSupplier {
16 | override val dataType: DataType get() = DataType.PERFORMER
17 |
18 | override fun createQuery(filter: FindFilterType?): Query =
19 | FindPerformersQuery(
20 | filter = filter,
21 | performer_filter = performerFilter,
22 | ids = null,
23 | )
24 |
25 | override fun getDefaultFilter(): FindFilterType = DataType.PERFORMER.asDefaultFindFilterType.merge(findFilter)
26 |
27 | override fun createCountQuery(filter: FindFilterType?): Query =
28 | CountPerformersQuery(filter, performerFilter, null)
29 |
30 | override fun parseCountQuery(data: CountPerformersQuery.Data): Int = data.findPerformers.count
31 |
32 | override fun parseQuery(data: FindPerformersQuery.Data): List = data.findPerformers.performers.map { it.performerData }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/suppliers/PerformerTagDataSupplier.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.suppliers
2 |
3 | import com.apollographql.apollo.api.Query
4 | import com.github.damontecres.stashapp.api.FindPerformerTagsQuery
5 | import com.github.damontecres.stashapp.api.fragment.TagData
6 | import com.github.damontecres.stashapp.api.type.FindFilterType
7 | import com.github.damontecres.stashapp.data.DataType
8 |
9 | /**
10 | * A DataSupplier that returns the tags for a performer
11 | */
12 | class PerformerTagDataSupplier(
13 | private val performerId: String,
14 | ) : StashPagingSource.DataSupplier {
15 | override val dataType: DataType
16 | get() = DataType.TAG
17 |
18 | override fun createQuery(filter: FindFilterType?): Query =
19 | FindPerformerTagsQuery(
20 | filter = filter,
21 | performer_filter = null,
22 | ids = listOf(performerId),
23 | )
24 |
25 | override fun getDefaultFilter(): FindFilterType = DataType.TAG.asDefaultFindFilterType
26 |
27 | override fun createCountQuery(filter: FindFilterType?): Query = createQuery(filter)
28 |
29 | override fun parseCountQuery(data: FindPerformerTagsQuery.Data): Int =
30 | data.findPerformers.performers
31 | .firstOrNull()
32 | ?.tags
33 | ?.size ?: 0
34 |
35 | override fun parseQuery(data: FindPerformerTagsQuery.Data): List =
36 | data.findPerformers.performers
37 | .firstOrNull()
38 | ?.tags
39 | ?.map {
40 | it.tagData
41 | }.orEmpty()
42 | }
43 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/suppliers/SceneDataSupplier.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.suppliers
2 |
3 | import com.apollographql.apollo.api.Query
4 | import com.github.damontecres.stashapp.api.CountScenesQuery
5 | import com.github.damontecres.stashapp.api.FindScenesQuery
6 | import com.github.damontecres.stashapp.api.fragment.SlimSceneData
7 | import com.github.damontecres.stashapp.api.type.FindFilterType
8 | import com.github.damontecres.stashapp.api.type.SceneFilterType
9 | import com.github.damontecres.stashapp.data.DataType
10 | import com.github.damontecres.stashapp.data.merge
11 |
12 | class SceneDataSupplier(
13 | private val findFilter: FindFilterType?,
14 | private val sceneFilter: SceneFilterType? = null,
15 | ) : StashPagingSource.DataSupplier {
16 | override val dataType: DataType get() = DataType.SCENE
17 |
18 | override fun createQuery(filter: FindFilterType?): Query =
19 | FindScenesQuery(
20 | filter = filter,
21 | scene_filter = sceneFilter,
22 | ids = null,
23 | )
24 |
25 | override fun parseQuery(data: FindScenesQuery.Data): List = data.findScenes.scenes.map { it.slimSceneData }
26 |
27 | override fun getDefaultFilter(): FindFilterType = DataType.SCENE.asDefaultFindFilterType.merge(findFilter)
28 |
29 | override fun createCountQuery(filter: FindFilterType?): Query = CountScenesQuery(filter, sceneFilter, null)
30 |
31 | override fun parseCountQuery(data: CountScenesQuery.Data): Int = data.findScenes.count
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/suppliers/TagDataSupplier.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.suppliers
2 |
3 | import com.apollographql.apollo.api.Query
4 | import com.github.damontecres.stashapp.api.CountTagsQuery
5 | import com.github.damontecres.stashapp.api.FindTagsQuery
6 | import com.github.damontecres.stashapp.api.fragment.TagData
7 | import com.github.damontecres.stashapp.api.type.FindFilterType
8 | import com.github.damontecres.stashapp.api.type.TagFilterType
9 | import com.github.damontecres.stashapp.data.DataType
10 | import com.github.damontecres.stashapp.data.merge
11 |
12 | class TagDataSupplier(
13 | private val findFilter: FindFilterType?,
14 | private val tagFilter: TagFilterType?,
15 | ) : StashPagingSource.DataSupplier {
16 | override val dataType: DataType get() = DataType.TAG
17 |
18 | override fun createQuery(filter: FindFilterType?): Query =
19 | FindTagsQuery(
20 | filter = filter,
21 | tag_filter = tagFilter,
22 | ids = null,
23 | )
24 |
25 | override fun getDefaultFilter(): FindFilterType = DataType.TAG.asDefaultFindFilterType.merge(findFilter)
26 |
27 | override fun createCountQuery(filter: FindFilterType?): Query = CountTagsQuery(filter, tagFilter)
28 |
29 | override fun parseCountQuery(data: CountTagsQuery.Data): Int = data.findTags.count
30 |
31 | override fun parseQuery(data: FindTagsQuery.Data): List = data.findTags.tags.map { it.tagData }
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/suppliers/VideoSceneDataSupplier.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.suppliers
2 |
3 | import com.apollographql.apollo.api.Query
4 | import com.github.damontecres.stashapp.api.CountScenesQuery
5 | import com.github.damontecres.stashapp.api.FindVideoScenesQuery
6 | import com.github.damontecres.stashapp.api.fragment.VideoSceneData
7 | import com.github.damontecres.stashapp.api.type.FindFilterType
8 | import com.github.damontecres.stashapp.api.type.SceneFilterType
9 | import com.github.damontecres.stashapp.data.DataType
10 |
11 | class VideoSceneDataSupplier(
12 | private val findFilter: FindFilterType?,
13 | private val sceneFilter: SceneFilterType? = null,
14 | ) : StashPagingSource.DataSupplier {
15 | override val dataType: DataType get() = DataType.SCENE
16 |
17 | override fun createQuery(filter: FindFilterType?): Query =
18 | FindVideoScenesQuery(
19 | filter = filter,
20 | scene_filter = sceneFilter,
21 | ids = null,
22 | )
23 |
24 | override fun parseQuery(data: FindVideoScenesQuery.Data): List = data.findScenes.scenes.map { it.videoSceneData }
25 |
26 | override fun getDefaultFilter(): FindFilterType = findFilter ?: DataType.SCENE.asDefaultFindFilterType
27 |
28 | override fun createCountQuery(filter: FindFilterType?): Query = CountScenesQuery(filter, sceneFilter, null)
29 |
30 | override fun parseCountQuery(data: CountScenesQuery.Data): Int = data.findScenes.count
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/components/CircularProgress.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.components
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.material3.CircularProgressIndicator
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.Modifier
9 | import androidx.tv.material3.MaterialTheme
10 | import com.github.damontecres.stashapp.ui.Material3AppTheme
11 | import com.github.damontecres.stashapp.ui.util.ifElse
12 |
13 | @Composable
14 | fun CircularProgress(
15 | modifier: Modifier = Modifier,
16 | fillMaxSize: Boolean = true,
17 | ) {
18 | Material3AppTheme {
19 | Box(modifier = modifier.ifElse(fillMaxSize, Modifier.fillMaxSize())) {
20 | CircularProgressIndicator(
21 | color = MaterialTheme.colorScheme.border,
22 | modifier = Modifier.align(Alignment.Center),
23 | )
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/components/ItemOnClicker.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.components
2 |
3 | import com.github.damontecres.stashapp.navigation.FilterAndPosition
4 |
5 | fun interface ItemOnClicker {
6 | fun onClick(
7 | item: T,
8 | filterAndPosition: FilterAndPosition?,
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/AmbientPlayerListener.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.components.playback
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.DisposableEffect
5 | import androidx.compose.ui.platform.LocalContext
6 | import androidx.media3.common.Player
7 | import androidx.media3.common.Player.Listener
8 | import com.github.damontecres.stashapp.util.findActivity
9 | import com.github.damontecres.stashapp.util.keepScreenOn
10 |
11 | @Composable
12 | fun AmbientPlayerListener(player: Player) {
13 | val context = LocalContext.current
14 | DisposableEffect(player) {
15 | val listener =
16 | object : Listener {
17 | override fun onIsPlayingChanged(isPlaying: Boolean) {
18 | context.findActivity()?.keepScreenOn(isPlaying)
19 | }
20 | }
21 | player.addListener(listener)
22 | onDispose {
23 | player.removeListener(listener)
24 | context.findActivity()?.keepScreenOn(false)
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/KeyIdentifier.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.components.playback
2 |
3 | import androidx.compose.ui.input.key.Key
4 | import androidx.compose.ui.input.key.KeyEvent
5 | import androidx.compose.ui.input.key.key
6 |
7 | fun isDpad(event: KeyEvent): Boolean =
8 | event.key == Key.DirectionCenter ||
9 | event.key == Key.DirectionUp ||
10 | event.key == Key.DirectionDown ||
11 | event.key == Key.DirectionLeft ||
12 | event.key == Key.DirectionRight ||
13 | event.key == Key.DirectionUpRight ||
14 | event.key == Key.DirectionUpLeft ||
15 | event.key == Key.DirectionDownRight ||
16 | event.key == Key.DirectionDownLeft
17 |
18 | fun isMedia(event: KeyEvent): Boolean =
19 | event.key == Key.MediaPlay ||
20 | event.key == Key.MediaPause ||
21 | event.key == Key.MediaPlayPause ||
22 | event.key == Key.MediaFastForward ||
23 | event.key == Key.MediaSkipForward ||
24 | event.key == Key.MediaRewind ||
25 | event.key == Key.MediaSkipBackward ||
26 | event.key == Key.MediaNext ||
27 | event.key == Key.MediaPrevious
28 |
29 | fun isBackwardButton(event: KeyEvent): Boolean =
30 | event.key == Key.PageUp ||
31 | event.key == Key.ChannelUp ||
32 | event.key == Key.MediaPrevious ||
33 | event.key == Key.MediaRewind ||
34 | event.key == Key.MediaSkipBackward
35 |
36 | fun isForwardButton(event: KeyEvent): Boolean =
37 | event.key == Key.PageDown ||
38 | event.key == Key.ChannelDown ||
39 | event.key == Key.MediaNext ||
40 | event.key == Key.MediaFastForward ||
41 | event.key == Key.MediaSkipForward
42 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackState.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.components.playback
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.mutableStateOf
5 |
6 | @Composable
7 | fun rememberPlaybackState(player: PlayerControls) {
8 | }
9 |
10 | class PlaybackState(
11 | player: PlayerControls,
12 | ) {
13 | var isPlaying = mutableStateOf(false)
14 | private set
15 | }
16 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/SeekBarState.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.components.playback
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.mutableStateOf
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.runtime.setValue
9 | import androidx.media3.common.Player
10 | import androidx.media3.common.listen
11 | import androidx.media3.common.util.UnstableApi
12 | import com.github.damontecres.stashapp.util.launchIO
13 | import kotlinx.coroutines.CoroutineScope
14 | import kotlinx.coroutines.Dispatchers
15 | import kotlinx.coroutines.Job
16 | import kotlinx.coroutines.delay
17 | import kotlinx.coroutines.withContext
18 |
19 | @UnstableApi
20 | @Composable
21 | fun rememberSeekBarState(
22 | player: Player,
23 | scope: CoroutineScope,
24 | ): SeekBarState {
25 | val seekBarState = remember(player) { SeekBarState(player, scope) }
26 | LaunchedEffect(player) {
27 | seekBarState.observe()
28 | }
29 | return seekBarState
30 | }
31 |
32 | @UnstableApi
33 | class SeekBarState(
34 | private val player: Player,
35 | private val scope: CoroutineScope,
36 | ) {
37 | var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SEEK_FORWARD))
38 | private set
39 |
40 | private var job: Job? = null
41 |
42 | fun onValueChange(progress: Float) {
43 | job?.cancel()
44 | job =
45 | scope.launchIO {
46 | delay(750L)
47 | withContext(Dispatchers.Main) {
48 | player.seekTo((player.duration * progress).toLong())
49 | }
50 | }
51 | }
52 |
53 | suspend fun observe(): Nothing =
54 | player.listen { events ->
55 | if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) {
56 | isEnabled = isCommandAvailable(Player.COMMAND_SEEK_FORWARD)
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/util/CoilPreviewTransformation.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.util
2 |
3 | import android.graphics.Bitmap
4 | import androidx.core.graphics.scale
5 | import coil3.size.Size
6 | import coil3.size.pxOrElse
7 | import coil3.transform.Transformation
8 | import com.github.damontecres.stashapp.util.StashPreviewLoader.GlideThumbnailTransformation.Companion.MAX_COLUMNS
9 | import com.github.damontecres.stashapp.util.StashPreviewLoader.GlideThumbnailTransformation.Companion.MAX_LINES
10 |
11 | class CoilPreviewTransformation(
12 | val targetWidth: Int,
13 | val targetHeight: Int,
14 | duration: Long,
15 | position: Long,
16 | ) : Transformation() {
17 | private val x: Int
18 | private val y: Int
19 |
20 | init {
21 | val square = position / (duration / (MAX_LINES * MAX_COLUMNS))
22 | y = square.toInt() / MAX_LINES
23 | x = square.toInt() % MAX_COLUMNS
24 | }
25 |
26 | override val cacheKey: String
27 | get() = "CoilPreviewTransformation_$x,$y"
28 |
29 | override suspend fun transform(
30 | input: Bitmap,
31 | size: Size,
32 | ): Bitmap {
33 | val width = input.width / MAX_COLUMNS
34 | val height = input.height / MAX_LINES
35 | // Log.d(TAG, "input.width=${input.width}, input.height=${input.height}, width=$width, height=$height, size=$size")
36 | return Bitmap
37 | .createBitmap(input, x * width, y * height, width, height)
38 | .scale(size.width.pxOrElse { targetWidth }, size.height.pxOrElse { targetHeight })
39 | }
40 |
41 | companion object {
42 | private const val TAG = "CoilPreviewTransformation"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/util/CrossFadeFactory.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.util
2 |
3 | import coil3.request.ImageResult
4 | import coil3.request.SuccessResult
5 | import coil3.transition.CrossfadeTransition
6 | import coil3.transition.Transition
7 | import coil3.transition.TransitionTarget
8 | import kotlin.time.Duration
9 | import kotlin.time.DurationUnit
10 |
11 | /**
12 | * Similar to [coil3.transition.CrossfadeTransition.Factory] but always cross fades even if loading from memory
13 | */
14 | class CrossFadeFactory(
15 | val duration: Duration,
16 | ) : Transition.Factory {
17 | override fun create(
18 | target: TransitionTarget,
19 | result: ImageResult,
20 | ): Transition =
21 | if (result is SuccessResult) {
22 | CrossfadeTransition(target, result, duration.toInt(DurationUnit.MILLISECONDS), false)
23 | } else {
24 | Transition.Factory.NONE.create(target, result)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/ui/util/OneTimeLaunchedEffect.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.ui.util
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.mutableStateOf
7 | import androidx.compose.runtime.saveable.rememberSaveable
8 | import androidx.compose.runtime.setValue
9 | import kotlinx.coroutines.CoroutineScope
10 |
11 | @Composable
12 | fun OneTimeLaunchedEffect(
13 | condition: Boolean,
14 | runOnceBlock: suspend CoroutineScope.() -> Unit,
15 | elseBlock: (suspend CoroutineScope.() -> Unit)? = null,
16 | ) {
17 | TODO("Work in progress")
18 | var hasRun by rememberSaveable { mutableStateOf(false) }
19 | if (!hasRun && condition) {
20 | LaunchedEffect(Unit) {
21 | runOnceBlock.invoke(this)
22 | hasRun = true
23 | }
24 | } else if (hasRun && elseBlock != null) {
25 | LaunchedEffect(Unit, elseBlock)
26 | }
27 | }
28 |
29 | /**
30 | * Run a [LaunchedEffect] exactly once even with multiple recompositions.
31 | *
32 | * If the composition is removed from the navigation back stack and "re-added", this will run again
33 | */
34 | @Composable
35 | fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) {
36 | var hasRun by rememberSaveable { mutableStateOf(false) }
37 | if (!hasRun) {
38 | LaunchedEffect(Unit) {
39 | hasRun = true
40 | runOnceBlock.invoke(this)
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/AndroidExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import com.github.damontecres.stashapp.data.DataType
4 | import com.github.damontecres.stashapp.data.DataType.GALLERY
5 | import com.github.damontecres.stashapp.data.DataType.GROUP
6 | import com.github.damontecres.stashapp.data.DataType.IMAGE
7 | import com.github.damontecres.stashapp.data.DataType.MARKER
8 | import com.github.damontecres.stashapp.data.DataType.PERFORMER
9 | import com.github.damontecres.stashapp.data.DataType.SCENE
10 | import com.github.damontecres.stashapp.data.DataType.STUDIO
11 | import com.github.damontecres.stashapp.data.DataType.TAG
12 |
13 | val DataType.defaultCardWidth
14 | get() =
15 | when (this) {
16 | SCENE -> 345
17 | GROUP -> 250
18 | MARKER -> 345
19 | PERFORMER -> 254 // 2/3 of height
20 | STUDIO -> 327
21 | TAG -> 250
22 | IMAGE -> 345
23 | GALLERY -> 345
24 | }
25 |
26 | val DataType.defaultCardHeight
27 | get() =
28 | when (this) {
29 | SCENE -> 194
30 | GROUP -> 250
31 | MARKER -> 194
32 | PERFORMER -> 381
33 | STUDIO -> 184
34 | TAG -> 250
35 | IMAGE -> 258
36 | GALLERY -> 258
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/Comparators.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import androidx.leanback.widget.DiffCallback
4 | import androidx.recyclerview.widget.DiffUtil
5 | import com.github.damontecres.stashapp.api.fragment.StashData
6 |
7 | object StashComparator : DiffUtil.ItemCallback() {
8 | override fun areItemsTheSame(
9 | oldItem: StashData,
10 | newItem: StashData,
11 | ): Boolean = oldItem::class == newItem::class && oldItem.id == newItem.id
12 |
13 | override fun areContentsTheSame(
14 | oldItem: StashData,
15 | newItem: StashData,
16 | ): Boolean = oldItem::class == newItem::class && oldItem == newItem
17 | }
18 |
19 | object StashDiffCallback : DiffCallback() {
20 | override fun areItemsTheSame(
21 | oldItem: StashData,
22 | newItem: StashData,
23 | ): Boolean = StashComparator.areItemsTheSame(oldItem, newItem)
24 |
25 | override fun areContentsTheSame(
26 | oldItem: StashData,
27 | newItem: StashData,
28 | ): Boolean = StashComparator.areContentsTheSame(oldItem, newItem)
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/CreateNew.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import com.github.damontecres.stashapp.data.DataType
4 |
5 | data class CreateNew(
6 | val dataType: DataType,
7 | val name: String,
8 | )
9 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/DefaultKeyEventCallback.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import android.view.KeyEvent
4 |
5 | interface DefaultKeyEventCallback : KeyEvent.Callback {
6 | override fun onKeyDown(
7 | keyCode: Int,
8 | event: KeyEvent,
9 | ): Boolean = false
10 |
11 | override fun onKeyUp(
12 | keyCode: Int,
13 | event: KeyEvent,
14 | ): Boolean = false
15 |
16 | override fun onKeyLongPress(
17 | keyCode: Int,
18 | event: KeyEvent,
19 | ): Boolean = false
20 |
21 | override fun onKeyMultiple(
22 | keyCode: Int,
23 | count: Int,
24 | event: KeyEvent,
25 | ): Boolean = false
26 | }
27 |
28 | interface DelegateKeyEventCallback : KeyEvent.Callback {
29 | val keyEventDelegate: KeyEvent.Callback?
30 |
31 | override fun onKeyDown(
32 | keyCode: Int,
33 | event: KeyEvent,
34 | ): Boolean = keyEventDelegate?.onKeyDown(keyCode, event) ?: false
35 |
36 | override fun onKeyUp(
37 | keyCode: Int,
38 | event: KeyEvent,
39 | ): Boolean = keyEventDelegate?.onKeyUp(keyCode, event) ?: false
40 |
41 | override fun onKeyLongPress(
42 | keyCode: Int,
43 | event: KeyEvent,
44 | ): Boolean = keyEventDelegate?.onKeyLongPress(keyCode, event) ?: false
45 |
46 | override fun onKeyMultiple(
47 | keyCode: Int,
48 | count: Int,
49 | event: KeyEvent,
50 | ): Boolean = keyEventDelegate?.onKeyMultiple(keyCode, count, event) ?: false
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/KeyEventDispatcher.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import android.view.KeyEvent
4 |
5 | /**
6 | * Fragments that implement this will receiving the events from the root activity
7 | */
8 | interface KeyEventDispatcher {
9 | fun dispatchKeyEvent(event: KeyEvent): Boolean
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/LongClickPreference.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import android.view.View
6 | import androidx.preference.Preference
7 | import androidx.preference.PreferenceViewHolder
8 |
9 | class LongClickPreference(
10 | context: Context,
11 | attrs: AttributeSet?,
12 | ) : Preference(context, attrs) {
13 | var longClickListener: View.OnLongClickListener? = null
14 |
15 | override fun onBindViewHolder(holder: PreferenceViewHolder) {
16 | super.onBindViewHolder(holder)
17 | holder.itemView.setOnLongClickListener {
18 | longClickListener?.onLongClick(it) ?: false
19 | }
20 | }
21 |
22 | fun setOnLongClickListener(longClickListener: View.OnLongClickListener?) {
23 | this.longClickListener = longClickListener
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/MappedList.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | /**
4 | * A list that lazily transforms items on [get]
5 | */
6 | class MappedList(
7 | private val sourceList: List,
8 | private val transform: (Int, T) -> R,
9 | ) : AbstractList() {
10 | constructor(
11 | sourceList: List,
12 | transform: (T) -> R,
13 | ) : this(sourceList, { _, x: T -> transform(x) })
14 |
15 | override val size: Int
16 | get() = sourceList.size
17 |
18 | override fun get(index: Int): R = transform(index, sourceList[index])
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/PageFilterKey.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import com.github.damontecres.stashapp.data.DataType
4 |
5 | /**
6 | * List of keys to look up how to sort items in a tab on a page
7 | */
8 | enum class PageFilterKey(
9 | val dataType: DataType,
10 | val prefKey: String,
11 | ) {
12 | TAG_MARKERS(DataType.MARKER, "tag_markers"),
13 | TAG_GALLERIES(DataType.GALLERY, "tag_galleries"),
14 | TAG_SCENES(DataType.SCENE, "tag_scenes"),
15 | TAG_IMAGES(DataType.IMAGE, "tag_images"),
16 | TAG_PERFORMERS(DataType.PERFORMER, "tag_performers"),
17 |
18 | PERFORMER_SCENES(DataType.SCENE, "performer_scenes"),
19 | PERFORMER_GALLERIES(DataType.GALLERY, "performer_galleries"),
20 | PERFORMER_IMAGES(DataType.IMAGE, "performer_images"),
21 | PERFORMER_GROUPS(DataType.GROUP, "performer_groups"),
22 | PERFORMER_APPEARS_WITH(DataType.PERFORMER, "performer_appears_with"),
23 |
24 | STUDIO_GALLERIES(DataType.GALLERY, "studio_galleries"),
25 | STUDIO_IMAGES(DataType.IMAGE, "studio_images"),
26 |
27 | GALLERY_IMAGES(DataType.IMAGE, "gallery_images"),
28 |
29 | STUDIO_SCENES(DataType.SCENE, "studio_scenes"),
30 | STUDIO_GROUPS(DataType.GROUP, "studio_groups"),
31 | STUDIO_PERFORMERS(DataType.PERFORMER, "studio_performers"),
32 | STUDIO_CHILDREN(DataType.STUDIO, "studio_children"),
33 |
34 | GROUP_SCENES(DataType.SCENE, "group_scenes"),
35 | GROUP_SUB_GROUPS(DataType.GROUP, "group_sub_groups"),
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/RemoveLongClickListener.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import android.util.Log
4 | import android.widget.Toast
5 | import com.github.damontecres.stashapp.api.fragment.StashData
6 | import com.github.damontecres.stashapp.filter.extractTitle
7 | import com.github.damontecres.stashapp.presenters.StashPresenter
8 | import com.github.damontecres.stashapp.presenters.StashPresenter.PopUpItem.Companion.REMOVE_POPUP_ITEM
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.launch
11 |
12 | fun createRemoveLongClickListener(
13 | scope: () -> CoroutineScope,
14 | rowManager: ListRowManager,
15 | ): StashPresenter.LongClickCallBack =
16 | StashPresenter
17 | .LongClickCallBack(
18 | StashPresenter.PopUpItem.DEFAULT to StashPresenter.PopUpAction { cardView, _ -> cardView.performClick() },
19 | ).addAction(REMOVE_POPUP_ITEM, { readOnlyModeDisabled() }) { cardView, item ->
20 | if (readOnlyModeDisabled()) {
21 | scope.invoke().launch(StashCoroutineExceptionHandler(autoToast = true)) {
22 | Log.v(
23 | "RemoveLongClickListener",
24 | "Removing id=${item.id} (${item::class.simpleName})",
25 | )
26 | if (rowManager.remove(item)) {
27 | val name = extractTitle(item)
28 | Toast.makeText(cardView.context, "Removed '$name'", Toast.LENGTH_SHORT).show()
29 | }
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/SingleItemObjectAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import androidx.leanback.widget.ObjectAdapter
4 | import com.github.damontecres.stashapp.presenters.StashPresenter
5 |
6 | class SingleItemObjectAdapter(
7 | presenter: StashPresenter,
8 | ) : ObjectAdapter(presenter) {
9 | constructor(presenter: StashPresenter, item: T) : this(presenter) {
10 | this.item = item
11 | }
12 |
13 | var item: T? = null
14 | set(newValue) {
15 | field = newValue
16 | notifyChanged()
17 | }
18 |
19 | override fun size(): Int = if (item == null) 0 else 1
20 |
21 | override fun get(position: Int): Any? {
22 | if (position != 0) {
23 | throw IllegalArgumentException()
24 | }
25 | return item
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/SkipParams.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | sealed interface SkipParams {
4 | data object Default : SkipParams
5 |
6 | data class Values(
7 | val skipForward: Long,
8 | val skipBack: Long,
9 | ) : SkipParams
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/StashCoroutineExceptionHandler.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import android.util.Log
4 | import android.widget.Toast
5 | import com.github.damontecres.stashapp.StashApplication
6 | import kotlinx.coroutines.CoroutineExceptionHandler
7 | import kotlin.coroutines.CoroutineContext
8 |
9 | /**
10 | * A general [CoroutineExceptionHandler] which can optionally show [Toast]s when an exception is thrown
11 | *
12 | * Note: a toast will be shown for each parameter given, up to three!
13 | *
14 | * @param autoToast automatically show a toast with the exception's message
15 | * @param toast the toast to show when an exception occurs
16 | * @param makeToast make a toast to show when an exception occurs
17 | */
18 | class StashCoroutineExceptionHandler(
19 | private val autoToast: Boolean = false,
20 | private val toast: Toast? = null,
21 | private val makeToast: ((Throwable) -> Toast)? = null,
22 | ) : CoroutineExceptionHandler {
23 | constructor(
24 | toast: Toast? = null,
25 | makeToast: ((Throwable) -> Toast)? = null,
26 | ) : this(false, toast, makeToast)
27 |
28 | override val key: CoroutineContext.Key<*>
29 | get() = CoroutineExceptionHandler
30 |
31 | override fun handleException(
32 | context: CoroutineContext,
33 | exception: Throwable,
34 | ) {
35 | Log.e(TAG, "Exception in coroutine", exception)
36 | toast?.show()
37 | makeToast?.let { it(exception).show() }
38 | if (autoToast) {
39 | Toast
40 | .makeText(
41 | StashApplication.getApplication(),
42 | "Error: ${exception.message}",
43 | Toast.LENGTH_LONG,
44 | ).show()
45 | }
46 | }
47 |
48 | companion object {
49 | const val TAG = "StashCoroutineExceptionHandler"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/StashFragmentPagerAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util
2 |
3 | import androidx.fragment.app.Fragment
4 | import androidx.fragment.app.FragmentManager
5 | import androidx.fragment.app.FragmentStatePagerAdapter
6 | import com.github.damontecres.stashapp.StashApplication
7 | import com.github.damontecres.stashapp.data.DataType
8 |
9 | /**
10 | * A [FragmentStatePagerAdapter] to show various tabs for data types
11 | */
12 | class StashFragmentPagerAdapter(
13 | private val items: List,
14 | fm: FragmentManager,
15 | ) : FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
16 | var fragmentCreatedListener: ((Fragment, Int) -> Unit)? = null
17 |
18 | override fun getCount(): Int = items.size
19 |
20 | override fun getItem(position: Int): Fragment {
21 | val newFragment = items[position].createFragment.invoke()
22 | fragmentCreatedListener?.invoke(newFragment, position)
23 | return newFragment
24 | }
25 |
26 | override fun getPageTitle(position: Int): CharSequence = items[position].title
27 |
28 | /**
29 | * Represents a tab with an title
30 | */
31 | data class PagerEntry(
32 | val title: String,
33 | val createFragment: () -> Fragment,
34 | ) {
35 | constructor(dataType: DataType, createFragment: () -> Fragment) : this(
36 | StashApplication.getApplication().getString(dataType.pluralStringId),
37 | createFragment,
38 | )
39 | }
40 |
41 | companion object {
42 | private const val TAG = "StashFragmentPagerAdapter"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/damontecres/stashapp/util/svg/SvgDecoder.kt:
--------------------------------------------------------------------------------
1 | package com.github.damontecres.stashapp.util.svg
2 |
3 | import com.bumptech.glide.load.Options
4 | import com.bumptech.glide.load.ResourceDecoder
5 | import com.bumptech.glide.load.engine.Resource
6 | import com.bumptech.glide.load.resource.SimpleResource
7 | import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
8 | import com.caverock.androidsvg.SVG
9 | import com.caverock.androidsvg.SVGParseException
10 | import java.io.IOException
11 | import java.io.InputStream
12 |
13 | // From https://github.com/bumptech/glide/tree/master/samples/svg/src/main/java/com/bumptech/glide/samples/svg
14 | class SvgDecoder : ResourceDecoder {
15 | override fun handles(
16 | source: InputStream,
17 | options: Options,
18 | ): Boolean {
19 | // TODO: Can we tell?
20 | return true
21 | }
22 |
23 | @Throws(IOException::class)
24 | override fun decode(
25 | source: InputStream,
26 | width: Int,
27 | height: Int,
28 | options: Options,
29 | ): Resource