Repository: ldm0206/bv Branch: develop Commit: d6687ab48d24 Files: 896 Total size: 3.9 MB Directory structure: gitextract_ya5631ky/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── alpha.yml │ ├── alpha_build_manually_without_sign.yml │ ├── auto_close_issues.yml │ ├── close_inactive_issues.yml │ ├── features.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .idea/ │ ├── .gitignore │ ├── kotlinc.xml │ ├── runConfigurations/ │ │ ├── Run_mobile.xml │ │ ├── Run_tv.xml │ │ └── app.xml │ └── vcs.xml ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── compose_compiler_config.conf │ ├── mobile/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── kotlin/ │ │ │ ├── com/ │ │ │ │ └── origeek/ │ │ │ │ └── imageViewer/ │ │ │ │ ├── gallery/ │ │ │ │ │ ├── ImageGallery.kt │ │ │ │ │ └── ImagePager.kt │ │ │ │ ├── previewer/ │ │ │ │ │ ├── ImagePreviewer.kt │ │ │ │ │ ├── ImageTransform.kt │ │ │ │ │ ├── ImageViewerContainer.kt │ │ │ │ │ ├── PreviewerPagerState.kt │ │ │ │ │ ├── PreviewerTransformState.kt │ │ │ │ │ └── PreviewerVerticalDragState.kt │ │ │ │ ├── util/ │ │ │ │ │ └── Ticket.kt │ │ │ │ └── viewer/ │ │ │ │ ├── ImageComposeCanvas.kt │ │ │ │ ├── ImageComposeOrigin.kt │ │ │ │ └── ImageViewer.kt │ │ │ └── dev/ │ │ │ └── aaa1115910/ │ │ │ └── bv/ │ │ │ └── mobile/ │ │ │ ├── activities/ │ │ │ │ ├── DynamicDetailActivity.kt │ │ │ │ ├── FavoriteActivity.kt │ │ │ │ ├── FollowingSeasonActivity.kt │ │ │ │ ├── FollowingUserActivity.kt │ │ │ │ ├── HistoryActivity.kt │ │ │ │ ├── IntentHandlerActivity.kt │ │ │ │ ├── LoginActivity.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── QrTokenResultActivity.kt │ │ │ │ ├── SettingsActivity.kt │ │ │ │ ├── UserSpaceActivity.kt │ │ │ │ └── VideoPlayerActivity.kt │ │ │ ├── component/ │ │ │ │ ├── home/ │ │ │ │ │ ├── SearchBar.kt │ │ │ │ │ ├── UserDialog.kt │ │ │ │ │ └── dynamic/ │ │ │ │ │ ├── DynamicItem.kt │ │ │ │ │ └── DynamicUserItem.kt │ │ │ │ ├── player/ │ │ │ │ │ └── VideoPlayerPages.kt │ │ │ │ ├── preferences/ │ │ │ │ │ ├── PreferenceGroup.kt │ │ │ │ │ ├── PreferencesPreview.kt │ │ │ │ │ └── items/ │ │ │ │ │ ├── BaseListItem.kt │ │ │ │ │ ├── ListItemPreference.kt │ │ │ │ │ ├── RadioPreference.kt │ │ │ │ │ ├── SwitchPreference.kt │ │ │ │ │ └── TextPreference.kt │ │ │ │ ├── reply/ │ │ │ │ │ ├── CommentItem.kt │ │ │ │ │ ├── Comments.kt │ │ │ │ │ ├── Replies.kt │ │ │ │ │ └── ReplySheetScaffold.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── PgcCard.kt │ │ │ │ │ ├── UgcCard.kt │ │ │ │ │ └── UserCard.kt │ │ │ │ ├── settings/ │ │ │ │ │ └── UpdateDialog.kt │ │ │ │ ├── user/ │ │ │ │ │ └── UserAvatar.kt │ │ │ │ └── videocard/ │ │ │ │ ├── RelatedVideoItem.kt │ │ │ │ ├── SeasonCard.kt │ │ │ │ ├── SmallVideoCard.kt │ │ │ │ ├── UpIcon.kt │ │ │ │ └── UpSpaceVideoItem.kt │ │ │ ├── screen/ │ │ │ │ ├── DynamicDetailScreen.kt │ │ │ │ ├── FavoriteScreen.kt │ │ │ │ ├── FollowingSeasonScreen.kt │ │ │ │ ├── FollowingUserScreen.kt │ │ │ │ ├── HistoryScreen.kt │ │ │ │ ├── LoginScreen.kt │ │ │ │ ├── MobileMainScreen.kt │ │ │ │ ├── QrTokenResultScreen.kt │ │ │ │ ├── RegionBlockScreen.kt │ │ │ │ ├── UserSpaceScreen.kt │ │ │ │ ├── VideoPlayerScreen.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── DynamicScreen.kt │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ ├── SearchScreen.kt │ │ │ │ │ ├── home/ │ │ │ │ │ │ ├── PopularPage.kt │ │ │ │ │ │ └── RcmdPage.kt │ │ │ │ │ └── search/ │ │ │ │ │ ├── SearchInput.kt │ │ │ │ │ └── SearchResult.kt │ │ │ │ └── settings/ │ │ │ │ ├── SettingsCategories.kt │ │ │ │ ├── SettingsDetails.kt │ │ │ │ ├── SettingsScreen.kt │ │ │ │ └── details/ │ │ │ │ ├── AboutContent.kt │ │ │ │ ├── AdvanceContent.kt │ │ │ │ ├── DebugContent.kt │ │ │ │ └── PlayContent.kt │ │ │ └── theme/ │ │ │ └── Theme.kt │ │ └── res/ │ │ └── values/ │ │ ├── strings.xml │ │ └── themes.xml │ ├── proguard-rules.pro │ ├── shared/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ ├── schemas/ │ │ │ └── dev.aaa1115910.bv.dao.AppDatabase/ │ │ │ ├── 1.json │ │ │ ├── 2.json │ │ │ └── 3.json │ │ └── src/ │ │ ├── debug/ │ │ │ ├── AndroidManifest.xml │ │ │ └── res/ │ │ │ └── values/ │ │ │ └── strings.xml │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin/ │ │ │ │ ├── coil/ │ │ │ │ │ └── transform/ │ │ │ │ │ └── BlurTransformation.kt │ │ │ │ ├── de/ │ │ │ │ │ └── schnettler/ │ │ │ │ │ └── datastore/ │ │ │ │ │ └── manager/ │ │ │ │ │ ├── DataStoreManager.kt │ │ │ │ │ └── PreferenceRequest.kt │ │ │ │ └── dev/ │ │ │ │ └── aaa1115910/ │ │ │ │ ├── bv/ │ │ │ │ │ ├── BVApp.kt │ │ │ │ │ ├── component/ │ │ │ │ │ │ ├── BvPlayerPreview.kt │ │ │ │ │ │ ├── DevelopingTip.kt │ │ │ │ │ │ ├── FpsMonitor.kt │ │ │ │ │ │ └── settings/ │ │ │ │ │ │ └── UpdateDialog.kt │ │ │ │ │ ├── dao/ │ │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ │ ├── SearchHistoryDao.kt │ │ │ │ │ │ └── UserDao.kt │ │ │ │ │ ├── entity/ │ │ │ │ │ │ ├── AuthData.kt │ │ │ │ │ │ ├── BvScheme.kt │ │ │ │ │ │ ├── PlayerType.kt │ │ │ │ │ │ ├── ThemeType.kt │ │ │ │ │ │ ├── carddata/ │ │ │ │ │ │ │ ├── SeasonCardData.kt │ │ │ │ │ │ │ └── VideoCardData.kt │ │ │ │ │ │ ├── db/ │ │ │ │ │ │ │ ├── SearchHistoryDB.kt │ │ │ │ │ │ │ └── UserDB.kt │ │ │ │ │ │ └── proxy/ │ │ │ │ │ │ └── ProxyArea.kt │ │ │ │ │ ├── network/ │ │ │ │ │ │ ├── GithubApi.kt │ │ │ │ │ │ ├── HttpServer.kt │ │ │ │ │ │ ├── VlcLibsApi.kt │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ └── GithubRelease.kt │ │ │ │ │ ├── repository/ │ │ │ │ │ │ ├── UserRepository.kt │ │ │ │ │ │ └── VideoInfoRepository.kt │ │ │ │ │ ├── ui/ │ │ │ │ │ │ └── theme/ │ │ │ │ │ │ ├── Theme.kt │ │ │ │ │ │ └── Typography.kt │ │ │ │ │ ├── util/ │ │ │ │ │ │ ├── AbiUtil.kt │ │ │ │ │ │ ├── BlacklistUtil.kt │ │ │ │ │ │ ├── CodecUtil.kt │ │ │ │ │ │ ├── EnumExtends.kt │ │ │ │ │ │ ├── Extends.kt │ │ │ │ │ │ ├── ImageExtends.kt │ │ │ │ │ │ ├── LogCatcherUtil.kt │ │ │ │ │ │ ├── ModifierExtends.kt │ │ │ │ │ │ ├── NetworkUtil.kt │ │ │ │ │ │ ├── NotYetImplemented.kt │ │ │ │ │ │ ├── PartitionUtil.kt │ │ │ │ │ │ ├── PgcIndexParamExtends.kt │ │ │ │ │ │ ├── PgcTypeExtends.kt │ │ │ │ │ │ ├── Prefs.kt │ │ │ │ │ │ ├── UgcTypeExtends.kt │ │ │ │ │ │ ├── UgcTypeV2Extends.kt │ │ │ │ │ │ └── calculateWindowSizeClassInPreview.kt │ │ │ │ │ └── viewmodel/ │ │ │ │ │ ├── CommentViewModel.kt │ │ │ │ │ ├── DynamicDetailViewModel.kt │ │ │ │ │ ├── SeasonViewModel.kt │ │ │ │ │ ├── TagViewModel.kt │ │ │ │ │ ├── UserSwitchViewModel.kt │ │ │ │ │ ├── UserViewModel.kt │ │ │ │ │ ├── VideoPlayerV3ViewModel.kt │ │ │ │ │ ├── home/ │ │ │ │ │ │ ├── DynamicViewModel.kt │ │ │ │ │ │ ├── PopularViewModel.kt │ │ │ │ │ │ └── RecommendViewModel.kt │ │ │ │ │ ├── index/ │ │ │ │ │ │ └── PgcIndexViewModel.kt │ │ │ │ │ ├── login/ │ │ │ │ │ │ ├── AppQrLoginViewModel.kt │ │ │ │ │ │ └── SmsLoginViewModel.kt │ │ │ │ │ ├── pgc/ │ │ │ │ │ │ ├── PgcAnimeViewModel.kt │ │ │ │ │ │ ├── PgcDocumentaryViewModel.kt │ │ │ │ │ │ ├── PgcGuoChuangViewModel.kt │ │ │ │ │ │ ├── PgcMovieViewModel.kt │ │ │ │ │ │ ├── PgcTvViewModel.kt │ │ │ │ │ │ ├── PgcVarietyViewModel.kt │ │ │ │ │ │ └── PgcViewModel.kt │ │ │ │ │ ├── search/ │ │ │ │ │ │ ├── SearchInputViewModel.kt │ │ │ │ │ │ └── SearchResultViewModel.kt │ │ │ │ │ ├── ugc/ │ │ │ │ │ │ ├── UgcAiViewModel.kt │ │ │ │ │ │ ├── UgcAnimalViewModel.kt │ │ │ │ │ │ ├── UgcCarViewModel.kt │ │ │ │ │ │ ├── UgcCinephileViewModel.kt │ │ │ │ │ │ ├── UgcDanceViewModel.kt │ │ │ │ │ │ ├── UgcDougaViewModel.kt │ │ │ │ │ │ ├── UgcEmotionViewModel.kt │ │ │ │ │ │ ├── UgcEntViewModel.kt │ │ │ │ │ │ ├── UgcFashionViewModel.kt │ │ │ │ │ │ ├── UgcFoodViewModel.kt │ │ │ │ │ │ ├── UgcGameViewModel.kt │ │ │ │ │ │ ├── UgcGymViewModel.kt │ │ │ │ │ │ ├── UgcHandmakeViewModel.kt │ │ │ │ │ │ ├── UgcHealthViewModel.kt │ │ │ │ │ │ ├── UgcHomeViewModel.kt │ │ │ │ │ │ ├── UgcInformationViewModel.kt │ │ │ │ │ │ ├── UgcKichikuViewModel.kt │ │ │ │ │ │ ├── UgcKnowledgeViewModel.kt │ │ │ │ │ │ ├── UgcLifeExperienceViewModel.kt │ │ │ │ │ │ ├── UgcLifeJoyViewModel.kt │ │ │ │ │ │ ├── UgcMusicViewModel.kt │ │ │ │ │ │ ├── UgcMysticismViewModel.kt │ │ │ │ │ │ ├── UgcOutdoorsViewModel.kt │ │ │ │ │ │ ├── UgcPaintingViewModel.kt │ │ │ │ │ │ ├── UgcParentingViewModel.kt │ │ │ │ │ │ ├── UgcRuralViewModel.kt │ │ │ │ │ │ ├── UgcShortplayViewModel.kt │ │ │ │ │ │ ├── UgcSportsViewModel.kt │ │ │ │ │ │ ├── UgcTechViewModel.kt │ │ │ │ │ │ ├── UgcTravelViewModel.kt │ │ │ │ │ │ ├── UgcViewModel.kt │ │ │ │ │ │ └── UgcVlogViewModel.kt │ │ │ │ │ ├── user/ │ │ │ │ │ │ ├── FavoriteViewModel.kt │ │ │ │ │ │ ├── FollowViewModel.kt │ │ │ │ │ │ ├── FollowingSeasonViewModel.kt │ │ │ │ │ │ ├── HistoryViewModel.kt │ │ │ │ │ │ ├── ToViewViewModel.kt │ │ │ │ │ │ └── UserSpaceViewModel.kt │ │ │ │ │ └── video/ │ │ │ │ │ └── VideoDetailViewModel.kt │ │ │ │ └── m3qrcode/ │ │ │ │ ├── DampedString.kt │ │ │ │ ├── EmphasizedInterpolator.kt │ │ │ │ ├── EntryAnimationStyle.kt │ │ │ │ ├── MaterialShapeQr.kt │ │ │ │ ├── MaterialShapeQrErrorCorrectionLevel.kt │ │ │ │ ├── MaterialShapeQrState.kt │ │ │ │ └── MaterialShapeRenderer.kt │ │ │ ├── proto/ │ │ │ │ └── blacklist.proto │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ ├── ic_banner_foreground.xml │ │ │ │ ├── ic_danmaku_count.xml │ │ │ │ ├── ic_gamer_ani.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ ├── ic_play_count.xml │ │ │ │ ├── ic_up.xml │ │ │ │ ├── qrcode_hor_bar_s2_capsule.xml │ │ │ │ ├── qrcode_hor_bar_s2_half_capsule.xml │ │ │ │ ├── qrcode_hor_bar_s3_capsule.xml │ │ │ │ ├── qrcode_hor_bar_s3_half_capsule.xml │ │ │ │ ├── qrcode_square_s1_circle.xml │ │ │ │ ├── qrcode_square_s1_drop.xml │ │ │ │ ├── qrcode_square_s1_semi_circle.xml │ │ │ │ ├── qrcode_square_s1_square.xml │ │ │ │ ├── qrcode_square_s2_circle.xml │ │ │ │ ├── qrcode_square_s2_clover.xml │ │ │ │ ├── qrcode_square_s2_hexagonal.xml │ │ │ │ ├── qrcode_square_s2_meteroid.xml │ │ │ │ ├── qrcode_square_s2_wiggle_star.xml │ │ │ │ ├── qrcode_square_s3_circle.xml │ │ │ │ ├── qrcode_square_s3_clover.xml │ │ │ │ ├── qrcode_square_s3_hexagonal.xml │ │ │ │ ├── qrcode_square_s3_meteroid.xml │ │ │ │ ├── qrcode_square_s3_wiggle_star.xml │ │ │ │ ├── qrcode_square_s7_ring.xml │ │ │ │ ├── qrcode_ver_bar_s2_capsule.xml │ │ │ │ └── qrcode_ver_bar_s3_capsule.xml │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ ├── ic_banner.xml │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── raw/ │ │ │ │ ├── ic_playing.json │ │ │ │ └── lottie_qrcode_background.json │ │ │ ├── values/ │ │ │ │ ├── arrays.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ └── themes.xml │ │ │ └── xml/ │ │ │ ├── network_security_config.xml │ │ │ └── provider_paths.xml │ │ ├── r8Test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── res/ │ │ │ └── values/ │ │ │ └── strings.xml │ │ └── test/ │ │ └── kotlin/ │ │ ├── android/ │ │ │ └── util/ │ │ │ └── Log.kt │ │ └── dev/ │ │ └── aaa1115910/ │ │ └── bv/ │ │ └── network/ │ │ └── GithubApiTest.kt │ ├── src/ │ │ └── main/ │ │ └── AndroidManifest.xml │ └── tv/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── kotlin/ │ │ └── dev/ │ │ └── aaa1115910/ │ │ └── bv/ │ │ └── tv/ │ │ ├── activities/ │ │ │ ├── MainActivity.kt │ │ │ ├── pgc/ │ │ │ │ ├── PgcIndexActivity.kt │ │ │ │ └── anime/ │ │ │ │ └── AnimeTimelineActivity.kt │ │ │ ├── search/ │ │ │ │ ├── SearchInputActivity.kt │ │ │ │ └── SearchResultActivity.kt │ │ │ ├── settings/ │ │ │ │ ├── LogsActivity.kt │ │ │ │ ├── MediaCodecActivity.kt │ │ │ │ ├── SettingsActivity.kt │ │ │ │ └── SpeedTestActivity.kt │ │ │ ├── user/ │ │ │ │ ├── FavoriteActivity.kt │ │ │ │ ├── FollowActivity.kt │ │ │ │ ├── FollowingSeasonActivity.kt │ │ │ │ ├── HistoryActivity.kt │ │ │ │ ├── LoginActivity.kt │ │ │ │ ├── ToViewActivity.kt │ │ │ │ ├── UserInfoActivity.kt │ │ │ │ ├── UserLockSettingsActivity.kt │ │ │ │ └── UserSwitchActivity.kt │ │ │ └── video/ │ │ │ ├── RemoteControllerPanelDemoActivity.kt │ │ │ ├── SeasonInfoActivity.kt │ │ │ ├── TagActivity.kt │ │ │ ├── UpInfoActivity.kt │ │ │ ├── VideoInfoActivity.kt │ │ │ └── VideoPlayerV3Activity.kt │ │ ├── component/ │ │ │ ├── Carousel.kt │ │ │ ├── LibVLCDownloaderDialog.kt │ │ │ ├── LoadingTip.kt │ │ │ ├── RemoteControlPanelDemo.kt │ │ │ ├── TopNav.kt │ │ │ ├── TvAlertDialog.kt │ │ │ ├── UpIcon.kt │ │ │ ├── UserPanel.kt │ │ │ ├── buttons/ │ │ │ │ ├── FavoriteButton.kt │ │ │ │ └── SeasonInfoButtons.kt │ │ │ ├── pgc/ │ │ │ │ └── IndexFilter.kt │ │ │ ├── search/ │ │ │ │ ├── SearchKeyword.kt │ │ │ │ └── SoftKeyboard.kt │ │ │ ├── settings/ │ │ │ │ ├── SettingListItem.kt │ │ │ │ ├── SettingSwitchListItem.kt │ │ │ │ ├── SettingsMenuSelectItem.kt │ │ │ │ └── UpdateDialog.kt │ │ │ └── videocard/ │ │ │ ├── LargeVideoCard.kt │ │ │ ├── SeasonCard.kt │ │ │ ├── SmallVideoCard.kt │ │ │ └── VideosRow.kt │ │ ├── screens/ │ │ │ ├── HomeScreen.kt │ │ │ ├── MainScreen.kt │ │ │ ├── RegionBlockScreen.kt │ │ │ ├── SeasonInfoScreen.kt │ │ │ ├── TagScreen.kt │ │ │ ├── VideoInfoScreen.kt │ │ │ ├── VideoPlayerV3Screen.kt │ │ │ ├── home/ │ │ │ │ └── DynamicsScreen.kt │ │ │ ├── login/ │ │ │ │ ├── AppQRLoginContent.kt │ │ │ │ ├── LoginScreen.kt │ │ │ │ └── SmsLoginContent.kt │ │ │ ├── main/ │ │ │ │ ├── DrawerContent.kt │ │ │ │ ├── HomeContent.kt │ │ │ │ ├── PgcContent.kt │ │ │ │ ├── UgcContent.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── DynamicsScreen.kt │ │ │ │ │ ├── PopularScreen.kt │ │ │ │ │ └── RecommendScreen.kt │ │ │ │ ├── pgc/ │ │ │ │ │ ├── AnimeContent.kt │ │ │ │ │ ├── DocumentaryContent.kt │ │ │ │ │ ├── GuoChuangContent.kt │ │ │ │ │ ├── MovieContent.kt │ │ │ │ │ ├── PgcCommon.kt │ │ │ │ │ ├── PgcIndexScreen.kt │ │ │ │ │ ├── TvContent.kt │ │ │ │ │ ├── VarietyContent.kt │ │ │ │ │ └── anime/ │ │ │ │ │ └── AnimeTimelineScreen.kt │ │ │ │ └── ugc/ │ │ │ │ ├── AiContent.kt │ │ │ │ ├── AnimalContent.kt │ │ │ │ ├── CarContent.kt │ │ │ │ ├── CinephileContent.kt │ │ │ │ ├── DanceContent.kt │ │ │ │ ├── DougaContent.kt │ │ │ │ ├── EmotionContent.kt │ │ │ │ ├── EntContent.kt │ │ │ │ ├── FashionContent.kt │ │ │ │ ├── FoodContent.kt │ │ │ │ ├── GameContent.kt │ │ │ │ ├── GymContent.kt │ │ │ │ ├── HandmakeContent.kt │ │ │ │ ├── HealthContent.kt │ │ │ │ ├── HomeContent.kt │ │ │ │ ├── InformationContent.kt │ │ │ │ ├── KichikuContent.kt │ │ │ │ ├── KnowledgeContent.kt │ │ │ │ ├── LifeExperienceContent.kt │ │ │ │ ├── LifeJoyContent.kt │ │ │ │ ├── MuiscContent.kt │ │ │ │ ├── MysticismContent.kt │ │ │ │ ├── OutdoorsContent.kt │ │ │ │ ├── PaintingContent.kt │ │ │ │ ├── ParentingContent.kt │ │ │ │ ├── RuralContent.kt │ │ │ │ ├── ShortPlayContent.kt │ │ │ │ ├── SportsContent.kt │ │ │ │ ├── TechContent.kt │ │ │ │ ├── TravelContent.kt │ │ │ │ ├── UgcChildRegionButtons.kt │ │ │ │ ├── UgcCommon.kt │ │ │ │ └── VlogContent.kt │ │ │ ├── search/ │ │ │ │ ├── SearchInputScreen.kt │ │ │ │ ├── SearchResultFilter.kt │ │ │ │ └── SearchResultScreen.kt │ │ │ ├── settings/ │ │ │ │ ├── LogsScreen.kt │ │ │ │ ├── MediaCodecScreen.kt │ │ │ │ ├── SettingsScreen.kt │ │ │ │ ├── SpeedTestScreen.kt │ │ │ │ └── content/ │ │ │ │ ├── AboutSetting.kt │ │ │ │ ├── ApiSetting.kt │ │ │ │ ├── AudioSetting.kt │ │ │ │ ├── InfoSetting.kt │ │ │ │ ├── NetworkSetting.kt │ │ │ │ ├── OtherSetting.kt │ │ │ │ ├── PlayerTypeSetting.kt │ │ │ │ ├── ResolutionSetting.kt │ │ │ │ ├── StorageSetting.kt │ │ │ │ ├── UISetting.kt │ │ │ │ └── VideoCodecSetting.kt │ │ │ └── user/ │ │ │ ├── FavoriteScreen.kt │ │ │ ├── FollowScreen.kt │ │ │ ├── FollowingSeasonFilter.kt │ │ │ ├── FollowingSeasonScreen.kt │ │ │ ├── HistoryScreen.kt │ │ │ ├── ToViewScreen.kt │ │ │ ├── UpInfoScreen.kt │ │ │ ├── UserInfoScreen.kt │ │ │ ├── UserSwitchScreen.kt │ │ │ └── lock/ │ │ │ ├── UnlockSwitchUserContent.kt │ │ │ ├── UnlockUserScreen.kt │ │ │ └── UserLockSettingsScreen.kt │ │ └── util/ │ │ └── PlayerActivityUtil.kt │ └── res/ │ └── values/ │ ├── strings.xml │ └── themes.xml ├── bili-api/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── example-response/ │ │ └── live-event/ │ │ ├── COMBO_SEND.json5 │ │ ├── DANMU_MSG.json5 │ │ ├── ENTRY_EFFECT.json5 │ │ ├── GUARD_BUY.json5 │ │ ├── HOT_RANK_CHANGED.json5 │ │ ├── HOT_RANK_CHANGED_V2.json5 │ │ ├── HOT_RANK_SETTLEMENT.json5 │ │ ├── HOT_RANK_SETTLEMENT_V2.json5 │ │ ├── HOT_ROOM_NOTIFY.json5 │ │ ├── INTERACT_WORD.json5 │ │ ├── LIKE_INFO_V3_CLICK.json5 │ │ ├── LIKE_INFO_V3_UPDATE.json5 │ │ ├── LIVE_INTERACTIVE_GAME.json5 │ │ ├── LIVE_MULTI_VIEW_CHANGE.json5 │ │ ├── NOTICE_MSG.json5 │ │ ├── ONLINE_RANK_COUNT.json5 │ │ ├── ONLINE_RANK_TOP3.json5 │ │ ├── ONLINE_RANK_V2.json5 │ │ ├── PREPARING.json5 │ │ ├── ROOM_REAL_TIME_MESSAGE_UPDATE.json │ │ ├── SEND_GIFT.json │ │ ├── STOP_LIVE_ROOM_LIST.json5 │ │ ├── SUPER_CHAT_ENTRANCE.json5 │ │ ├── SUPER_CHAT_MESSAGE.json5 │ │ ├── SUPER_CHAT_MESSAGE_JPN.json5 │ │ ├── USER_TOAST_MSG.json5 │ │ ├── WATCHED_CHANGE.json5 │ │ └── WIDGET_BANNER.json5 │ ├── grpc/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── proto/ │ │ ├── bilibili/ │ │ │ ├── account/ │ │ │ │ └── fission/ │ │ │ │ └── v1/ │ │ │ │ └── fission.proto │ │ │ ├── ad/ │ │ │ │ └── v1/ │ │ │ │ └── ad.proto │ │ │ ├── api/ │ │ │ │ ├── player/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── player.proto │ │ │ │ ├── probe/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── probe.proto │ │ │ │ └── ticket/ │ │ │ │ └── v1/ │ │ │ │ └── ticket.proto │ │ │ ├── app/ │ │ │ │ ├── archive/ │ │ │ │ │ ├── middleware/ │ │ │ │ │ │ └── v1/ │ │ │ │ │ │ └── preload.proto │ │ │ │ │ └── v1/ │ │ │ │ │ └── archive.proto │ │ │ │ ├── card/ │ │ │ │ │ └── v1/ │ │ │ │ │ ├── ad.proto │ │ │ │ │ ├── card.proto │ │ │ │ │ ├── common.proto │ │ │ │ │ ├── double.proto │ │ │ │ │ └── single.proto │ │ │ │ ├── click/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── heartbeat.proto │ │ │ │ ├── distribution/ │ │ │ │ │ ├── setting/ │ │ │ │ │ │ ├── download.proto │ │ │ │ │ │ ├── dynamic.proto │ │ │ │ │ │ ├── experimental.proto │ │ │ │ │ │ ├── internaldevice.proto │ │ │ │ │ │ ├── night.proto │ │ │ │ │ │ ├── other.proto │ │ │ │ │ │ ├── pegasus.proto │ │ │ │ │ │ ├── play.proto │ │ │ │ │ │ ├── privacy.proto │ │ │ │ │ │ └── search.proto │ │ │ │ │ └── v1/ │ │ │ │ │ └── distribution.proto │ │ │ │ ├── dynamic/ │ │ │ │ │ ├── common/ │ │ │ │ │ │ └── dynamic.proto │ │ │ │ │ ├── v1/ │ │ │ │ │ │ └── dynamic.proto │ │ │ │ │ └── v2/ │ │ │ │ │ ├── campus.proto │ │ │ │ │ ├── dynamic.proto │ │ │ │ │ └── opus.proto │ │ │ │ ├── interfaces/ │ │ │ │ │ └── v1/ │ │ │ │ │ ├── history.proto │ │ │ │ │ ├── media.proto │ │ │ │ │ ├── search.proto │ │ │ │ │ └── space.proto │ │ │ │ ├── listener/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── listener.proto │ │ │ │ ├── playeronline/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── playeronline.proto │ │ │ │ ├── playerunite/ │ │ │ │ │ ├── pgcanymodel/ │ │ │ │ │ │ └── PGCAnyModel.proto │ │ │ │ │ ├── ugcanymodel/ │ │ │ │ │ │ └── UGCAnyModel.proto │ │ │ │ │ └── v1/ │ │ │ │ │ └── playerunite.proto │ │ │ │ ├── playurl/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── playurl.proto │ │ │ │ ├── resource/ │ │ │ │ │ ├── privacy/ │ │ │ │ │ │ └── v1/ │ │ │ │ │ │ └── api.proto │ │ │ │ │ └── v1/ │ │ │ │ │ └── module.proto │ │ │ │ ├── search/ │ │ │ │ │ └── v2/ │ │ │ │ │ └── search.proto │ │ │ │ ├── show/ │ │ │ │ │ ├── gateway/ │ │ │ │ │ │ └── v1/ │ │ │ │ │ │ └── service.proto │ │ │ │ │ ├── mixture/ │ │ │ │ │ │ └── v1/ │ │ │ │ │ │ └── mixture.proto │ │ │ │ │ ├── popular/ │ │ │ │ │ │ └── v1/ │ │ │ │ │ │ └── popular.proto │ │ │ │ │ ├── rank/ │ │ │ │ │ │ └── v1/ │ │ │ │ │ │ └── rank.proto │ │ │ │ │ └── region/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── region.proto │ │ │ │ ├── space/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── space.proto │ │ │ │ ├── splash/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── splash.proto │ │ │ │ ├── topic/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── topic.proto │ │ │ │ ├── view/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── view.proto │ │ │ │ ├── viewunite/ │ │ │ │ │ ├── common.proto │ │ │ │ │ ├── pgcanymodel.proto │ │ │ │ │ ├── ugcanymodel.proto │ │ │ │ │ └── v1/ │ │ │ │ │ └── viewunite.proto │ │ │ │ └── wall/ │ │ │ │ └── v1/ │ │ │ │ └── wall.proto │ │ │ ├── broadcast/ │ │ │ │ ├── message/ │ │ │ │ │ ├── editor/ │ │ │ │ │ │ └── notify.proto │ │ │ │ │ ├── esports/ │ │ │ │ │ │ └── notify.proto │ │ │ │ │ ├── fission/ │ │ │ │ │ │ └── notify.proto │ │ │ │ │ ├── im/ │ │ │ │ │ │ └── notify.proto │ │ │ │ │ ├── main/ │ │ │ │ │ │ ├── dm.proto │ │ │ │ │ │ ├── native.proto │ │ │ │ │ │ ├── resource.proto │ │ │ │ │ │ └── search.proto │ │ │ │ │ ├── note/ │ │ │ │ │ │ └── sync.proto │ │ │ │ │ ├── ogv/ │ │ │ │ │ │ ├── freya.proto │ │ │ │ │ │ └── live.proto │ │ │ │ │ ├── ticket/ │ │ │ │ │ │ └── activitygame.proto │ │ │ │ │ └── tv/ │ │ │ │ │ └── proj.proto │ │ │ │ ├── v1/ │ │ │ │ │ ├── broadcast.proto │ │ │ │ │ ├── laser.proto │ │ │ │ │ ├── mod.proto │ │ │ │ │ ├── push.proto │ │ │ │ │ ├── room.proto │ │ │ │ │ └── test.proto │ │ │ │ └── v2/ │ │ │ │ └── laser.proto │ │ │ ├── cheese/ │ │ │ │ └── gateway/ │ │ │ │ └── player/ │ │ │ │ └── v1/ │ │ │ │ └── playurl.proto │ │ │ ├── community/ │ │ │ │ └── service/ │ │ │ │ ├── dm/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── dm.proto │ │ │ │ └── govern/ │ │ │ │ └── v1/ │ │ │ │ └── govern.proto │ │ │ ├── dagw/ │ │ │ │ └── component/ │ │ │ │ └── avatar/ │ │ │ │ ├── common/ │ │ │ │ │ └── common.proto │ │ │ │ └── v1/ │ │ │ │ ├── avatar.proto │ │ │ │ └── plugin.proto │ │ │ ├── dynamic/ │ │ │ │ ├── common/ │ │ │ │ │ └── dynamic.proto │ │ │ │ ├── gw/ │ │ │ │ │ └── gateway.proto │ │ │ │ └── interfaces/ │ │ │ │ └── feed/ │ │ │ │ └── v1/ │ │ │ │ └── api.proto │ │ │ ├── gaia/ │ │ │ │ └── gw/ │ │ │ │ └── gw_api.proto │ │ │ ├── im/ │ │ │ │ ├── interfaces/ │ │ │ │ │ ├── inner-interface/ │ │ │ │ │ │ └── v1/ │ │ │ │ │ │ └── api.proto │ │ │ │ │ └── v1/ │ │ │ │ │ └── im.proto │ │ │ │ └── type/ │ │ │ │ └── im.proto │ │ │ ├── live/ │ │ │ │ ├── app/ │ │ │ │ │ └── room/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── room.proto │ │ │ │ └── general/ │ │ │ │ └── interfaces/ │ │ │ │ └── v1/ │ │ │ │ └── interfaces.proto │ │ │ ├── main/ │ │ │ │ ├── common/ │ │ │ │ │ └── arch/ │ │ │ │ │ └── doll/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── doll.proto │ │ │ │ └── community/ │ │ │ │ └── reply/ │ │ │ │ └── v1/ │ │ │ │ └── reply.proto │ │ │ ├── metadata/ │ │ │ │ ├── device/ │ │ │ │ │ └── device.proto │ │ │ │ ├── fawkes/ │ │ │ │ │ └── fawkes.proto │ │ │ │ ├── locale/ │ │ │ │ │ └── locale.proto │ │ │ │ ├── metadata.proto │ │ │ │ ├── network/ │ │ │ │ │ └── network.proto │ │ │ │ ├── parabox/ │ │ │ │ │ └── parabox.proto │ │ │ │ └── restriction/ │ │ │ │ └── restriction.proto │ │ │ ├── pagination/ │ │ │ │ └── pagination.proto │ │ │ ├── pangu/ │ │ │ │ └── gallery/ │ │ │ │ └── v1/ │ │ │ │ └── gallery.proto │ │ │ ├── pgc/ │ │ │ │ ├── gateway/ │ │ │ │ │ └── player/ │ │ │ │ │ ├── v1/ │ │ │ │ │ │ └── playurl.proto │ │ │ │ │ └── v2/ │ │ │ │ │ └── playurl.proto │ │ │ │ └── service/ │ │ │ │ └── premiere/ │ │ │ │ └── v1/ │ │ │ │ └── premiere.proto │ │ │ ├── playershared/ │ │ │ │ └── playershared.proto │ │ │ ├── polymer/ │ │ │ │ ├── app/ │ │ │ │ │ └── search/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── search.proto │ │ │ │ ├── community/ │ │ │ │ │ └── govern/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── govern.proto │ │ │ │ ├── contract/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── contract.proto │ │ │ │ ├── demo/ │ │ │ │ │ └── demo.proto │ │ │ │ └── list/ │ │ │ │ └── v1/ │ │ │ │ └── list.proto │ │ │ ├── relation/ │ │ │ │ └── interfaces/ │ │ │ │ └── api.proto │ │ │ ├── render/ │ │ │ │ └── render.proto │ │ │ ├── rpc/ │ │ │ │ └── status.proto │ │ │ ├── tv/ │ │ │ │ └── interfaces/ │ │ │ │ └── dm/ │ │ │ │ └── v1/ │ │ │ │ └── dm.proto │ │ │ ├── vega/ │ │ │ │ └── deneb/ │ │ │ │ └── v1/ │ │ │ │ └── deneb.proto │ │ │ └── web/ │ │ │ ├── interfaces/ │ │ │ │ └── v1/ │ │ │ │ └── interfaces.proto │ │ │ └── space/ │ │ │ └── v1/ │ │ │ └── space.proto │ │ ├── common/ │ │ │ └── ErrorProto.proto │ │ ├── datacenter/ │ │ │ └── hakase/ │ │ │ └── protobuf/ │ │ │ └── android_device_info.proto │ │ └── pgc/ │ │ ├── biz/ │ │ │ └── room.proto │ │ └── gateway/ │ │ └── vega/ │ │ └── v1/ │ │ └── vega.proto │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ ├── com/ │ │ │ └── tfowl/ │ │ │ └── ktor/ │ │ │ └── client/ │ │ │ └── plugins/ │ │ │ └── JsoupPlugin.kt │ │ └── dev/ │ │ └── aaa1115910/ │ │ └── biliapi/ │ │ ├── entity/ │ │ │ ├── ApiType.kt │ │ │ ├── CarouselData.kt │ │ │ ├── CodeType.kt │ │ │ ├── Favorite.kt │ │ │ ├── Picture.kt │ │ │ ├── PlayData.kt │ │ │ ├── danmaku/ │ │ │ │ └── DanmakuMask.kt │ │ │ ├── home/ │ │ │ │ └── RecommendData.kt │ │ │ ├── login/ │ │ │ │ ├── Captcha.kt │ │ │ │ ├── QR.kt │ │ │ │ └── Sms.kt │ │ │ ├── pgc/ │ │ │ │ ├── PgcFeedData.kt │ │ │ │ ├── PgcItem.kt │ │ │ │ ├── PgcType.kt │ │ │ │ └── index/ │ │ │ │ ├── IndexParams.kt │ │ │ │ └── PgcIndexData.kt │ │ │ ├── rank/ │ │ │ │ └── Popular.kt │ │ │ ├── reply/ │ │ │ │ ├── Comment.kt │ │ │ │ ├── CommentPage.kt │ │ │ │ ├── CommentRepliesData.kt │ │ │ │ └── CommentSort.kt │ │ │ ├── search/ │ │ │ │ └── Hotword.kt │ │ │ ├── season/ │ │ │ │ ├── FollowingSeasons.kt │ │ │ │ ├── IndexResult.kt │ │ │ │ └── Timeline.kt │ │ │ ├── ugc/ │ │ │ │ ├── UgcItem.kt │ │ │ │ ├── UgcType.kt │ │ │ │ ├── UgcTypeV2.kt │ │ │ │ └── region/ │ │ │ │ ├── UgcFeedData.kt │ │ │ │ ├── UgcFeedPage.kt │ │ │ │ ├── UgcRegionData.kt │ │ │ │ ├── UgcRegionListData.kt │ │ │ │ └── UgcRegionPage.kt │ │ │ ├── user/ │ │ │ │ ├── Author.kt │ │ │ │ ├── Dynamic.kt │ │ │ │ ├── FollowedUser.kt │ │ │ │ ├── History.kt │ │ │ │ ├── Space.kt │ │ │ │ └── ToView.kt │ │ │ └── video/ │ │ │ ├── Dimension.kt │ │ │ ├── Heartbeat.kt │ │ │ ├── RelatedVideo.kt │ │ │ ├── Subtitle.kt │ │ │ ├── Tag.kt │ │ │ ├── VideoDetail.kt │ │ │ ├── VideoPage.kt │ │ │ ├── VideoShot.kt │ │ │ └── season/ │ │ │ ├── Episode.kt │ │ │ ├── PgcSeason.kt │ │ │ ├── SeasonDetail.kt │ │ │ └── Section.kt │ │ ├── grpc/ │ │ │ └── utils/ │ │ │ ├── Channel.kt │ │ │ └── StatusExtends.kt │ │ ├── http/ │ │ │ ├── BiliHttpApi.kt │ │ │ ├── BiliHttpProxyApi.kt │ │ │ ├── BiliLiveHttpApi.kt │ │ │ ├── BiliPassportHttpApi.kt │ │ │ ├── BiliPlusHttpApi.kt │ │ │ ├── entity/ │ │ │ │ ├── BiliResponse.kt │ │ │ │ ├── biliplus/ │ │ │ │ │ └── View.kt │ │ │ │ ├── danmaku/ │ │ │ │ │ └── DanmakuResponse.kt │ │ │ │ ├── dynamic/ │ │ │ │ │ ├── DynamicDetailResponse.kt │ │ │ │ │ └── DynamicResponse.kt │ │ │ │ ├── history/ │ │ │ │ │ └── HistoryData.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── RcmdIndexData.kt │ │ │ │ │ └── RcmdTopData.kt │ │ │ │ ├── index/ │ │ │ │ │ ├── IndexFilter.kt │ │ │ │ │ ├── IndexFilterArea.kt │ │ │ │ │ ├── IndexFilterProducerId.kt │ │ │ │ │ ├── IndexFilterStyle.kt │ │ │ │ │ ├── IndexOrder.kt │ │ │ │ │ └── IndexResult.kt │ │ │ │ ├── live/ │ │ │ │ │ ├── HistoryDanmaku.kt │ │ │ │ │ ├── LiveDanmuInfoResponse.kt │ │ │ │ │ ├── LiveEvent.kt │ │ │ │ │ ├── LiveFrame.kt │ │ │ │ │ └── LiveRoomPlayInfoResponse.kt │ │ │ │ ├── login/ │ │ │ │ │ ├── Captcha.kt │ │ │ │ │ ├── qr/ │ │ │ │ │ │ ├── AppQR.kt │ │ │ │ │ │ └── WebQR.kt │ │ │ │ │ └── sms/ │ │ │ │ │ ├── SendSmsResponse.kt │ │ │ │ │ └── SmsLoginResponse.kt │ │ │ │ ├── pgc/ │ │ │ │ │ ├── PgcFeed.kt │ │ │ │ │ ├── PgcFeedV3.kt │ │ │ │ │ └── PgcWebInitialStateData.kt │ │ │ │ ├── proxy/ │ │ │ │ │ └── PlayUrl.kt │ │ │ │ ├── region/ │ │ │ │ │ ├── RegionBanner.kt │ │ │ │ │ ├── RegionDynamic.kt │ │ │ │ │ ├── RegionDynamicList.kt │ │ │ │ │ ├── RegionFeedRcmd.kt │ │ │ │ │ └── RegionLocs.kt │ │ │ │ ├── reply/ │ │ │ │ │ ├── Comment.kt │ │ │ │ │ ├── CommentReplyData.kt │ │ │ │ │ └── Layers.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── KeywordSuggest.kt │ │ │ │ │ ├── SearchCost.kt │ │ │ │ │ ├── SearchResult.kt │ │ │ │ │ ├── SearchResultItem.kt │ │ │ │ │ └── SearchSquare.kt │ │ │ │ ├── season/ │ │ │ │ │ ├── AppSeasonData.kt │ │ │ │ │ ├── Episode.kt │ │ │ │ │ ├── Follow.kt │ │ │ │ │ ├── SeasonSection.kt │ │ │ │ │ ├── WebFollowingSeason.kt │ │ │ │ │ └── WebSeasonData.kt │ │ │ │ ├── subtitle/ │ │ │ │ │ └── Subtitle.kt │ │ │ │ ├── toview/ │ │ │ │ │ └── ToViewData.kt │ │ │ │ ├── user/ │ │ │ │ │ ├── Follow.kt │ │ │ │ │ ├── LevelInfo.kt │ │ │ │ │ ├── Nameplate.kt │ │ │ │ │ ├── Official.kt │ │ │ │ │ ├── Pendant.kt │ │ │ │ │ ├── Profession.kt │ │ │ │ │ ├── Relation.kt │ │ │ │ │ ├── SpaceVideoData.kt │ │ │ │ │ ├── Staff.kt │ │ │ │ │ ├── UserCardInfoResponse.kt │ │ │ │ │ ├── UserGarb.kt │ │ │ │ │ ├── UserHonours.kt │ │ │ │ │ ├── UserInfoResponse.kt │ │ │ │ │ ├── UserSelfInfoResponse.kt │ │ │ │ │ ├── Vip.kt │ │ │ │ │ ├── favorite/ │ │ │ │ │ │ ├── CntInfo.kt │ │ │ │ │ │ ├── FavoriteFolderInfo.kt │ │ │ │ │ │ ├── FavoriteFolderInfoListData.kt │ │ │ │ │ │ ├── FavoriteItem.kt │ │ │ │ │ │ ├── Upper.kt │ │ │ │ │ │ └── UserFavoriteFoldersData.kt │ │ │ │ │ └── garb/ │ │ │ │ │ ├── CardBg.kt │ │ │ │ │ ├── Equip.kt │ │ │ │ │ └── Item.kt │ │ │ │ ├── video/ │ │ │ │ │ ├── AddCoin.kt │ │ │ │ │ ├── PlayUrlResponse.kt │ │ │ │ │ ├── PopularVideosResponse.kt │ │ │ │ │ ├── RelatedVideosResponse.kt │ │ │ │ │ ├── SetVideoFavorite.kt │ │ │ │ │ ├── Tag.kt │ │ │ │ │ ├── Timeline.kt │ │ │ │ │ ├── UgcSeason.kt │ │ │ │ │ ├── VideoDetail.kt │ │ │ │ │ ├── VideoInfo.kt │ │ │ │ │ ├── VideoMoreInfo.kt │ │ │ │ │ └── VideoShot.kt │ │ │ │ └── web/ │ │ │ │ ├── Hover.kt │ │ │ │ └── Nav.kt │ │ │ ├── plugins/ │ │ │ │ └── BiliUserAgent.kt │ │ │ └── util/ │ │ │ ├── ApiSign.kt │ │ │ ├── BiliAppConf.kt │ │ │ ├── BiliWebConf.kt │ │ │ ├── Buvid.kt │ │ │ ├── CommonEnumIntSerializer.kt │ │ │ └── Zlib.kt │ │ ├── repositories/ │ │ │ ├── AuthRepository.kt │ │ │ ├── BiliApiModule.kt │ │ │ ├── ChannelRepository.kt │ │ │ ├── CommentRepository.kt │ │ │ ├── FavoriteRepository.kt │ │ │ ├── HistoryRepository.kt │ │ │ ├── LoginRepository.kt │ │ │ ├── PgcRepository.kt │ │ │ ├── RecommendVideoRepository.kt │ │ │ ├── SearchRepository.kt │ │ │ ├── SeasonRepository.kt │ │ │ ├── ToViewRepository.kt │ │ │ ├── UgcRepository.kt │ │ │ ├── UserRepository.kt │ │ │ ├── VideoDetailRepository.kt │ │ │ └── VideoPlayRepository.kt │ │ ├── util/ │ │ │ ├── AvBvConverter.kt │ │ │ ├── Extends.kt │ │ │ └── UrlUtil.kt │ │ └── websocket/ │ │ └── LiveDataWebSocket.kt │ └── test/ │ ├── kotlin/ │ │ └── dev/ │ │ └── aaa1115910/ │ │ └── biliapi/ │ │ ├── BvLoginRepositoryTest.kt │ │ ├── entity/ │ │ │ └── DanmakuMaskTest.kt │ │ ├── http/ │ │ │ ├── BiliHttpApiTest.kt │ │ │ ├── BiliLiveHttpApiTest.kt │ │ │ ├── BiliPassportHttpApiTest.kt │ │ │ └── BiliPlusHttpApiTest.kt │ │ ├── repositories/ │ │ │ ├── CommentRepositoryTest.kt │ │ │ ├── FavoriteRepositoryTest.kt │ │ │ ├── HistoryRepositoryTest.kt │ │ │ ├── PgcRepositoryTest.kt │ │ │ ├── RecommendVideoRepositoryTest.kt │ │ │ ├── SearchRepositoryTest.kt │ │ │ ├── SeasonRepositoryTest.kt │ │ │ ├── UgcRepositoryTest.kt │ │ │ ├── UserRepositoryTest.kt │ │ │ ├── VideoDetailRepositoryTest.kt │ │ │ └── VideoPlayRepositoryTest.kt │ │ └── websocket/ │ │ └── LiveDataWebSocketTest.kt │ └── resources/ │ ├── 3540266_25_2.exp.mobmask │ └── 3540266_25_2.exp.webmask ├── bili-subtitle/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── dev/ │ │ └── aaa1115910/ │ │ └── bilisubtitle/ │ │ ├── SubtitleEncoder.kt │ │ ├── SubtitleParser.kt │ │ └── entity/ │ │ ├── BiliSubtitle.kt │ │ ├── SrtSubtitle.kt │ │ ├── SubtitleItem.kt │ │ └── Timestamp.kt │ └── test/ │ ├── kotlin/ │ │ └── dev/ │ │ └── aaa1115910/ │ │ └── bilisubtitle/ │ │ ├── SubtitleEncoderTest.kt │ │ ├── SubtitleParserTest.kt │ │ └── entity/ │ │ └── TimestampTest.kt │ └── resources/ │ ├── example.bcc │ └── example.srt ├── build.gradle.kts ├── buildSrc/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ ├── AppConfiguration.kt │ └── ProtobufConfiguration.kt ├── gradle/ │ ├── androidx.versions.toml │ ├── gradle.versions.toml │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── player/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── core/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── GlobalSign ECC Root CA R5.crt │ │ └── kotlin/ │ │ └── dev/ │ │ └── aaa1115910/ │ │ └── bv/ │ │ └── player/ │ │ ├── AbstractVideoPlayer.kt │ │ ├── BvVideoPlayer.kt │ │ ├── OkHttpUtil.kt │ │ ├── VideoPlayerListener.kt │ │ ├── VideoPlayerOptions.kt │ │ ├── factory/ │ │ │ └── PlayerFactory.kt │ │ └── impl/ │ │ └── exo/ │ │ ├── ExoMediaPlayer.kt │ │ └── ExoPlayerFactory.kt │ ├── mobile/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── kotlin/ │ │ └── dev/ │ │ └── aaa1115910/ │ │ └── bv/ │ │ └── player/ │ │ └── mobile/ │ │ ├── BvPlayer.kt │ │ ├── MaterialDarkTheme.kt │ │ ├── Media3VideoPlayer.kt │ │ ├── NoRippleClickable.kt │ │ ├── SeekBar.kt │ │ └── controller/ │ │ ├── BvPlayerController.kt │ │ ├── FullscreenControllers.kt │ │ ├── MiniControllers.kt │ │ ├── Tips.kt │ │ └── menu/ │ │ ├── DanmakuMenu.kt │ │ ├── DashMenu.kt │ │ ├── MoreMenu.kt │ │ ├── SpeedMenu.kt │ │ └── VideoListMenu.kt │ ├── shared/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── kotlin/ │ │ │ └── dev/ │ │ │ └── aaa1115910/ │ │ │ └── bv/ │ │ │ └── player/ │ │ │ ├── AkDanmakuPlayer.kt │ │ │ ├── entity/ │ │ │ │ ├── Audio.kt │ │ │ │ ├── DanmakuSize.kt │ │ │ │ ├── DanmakuTransparency.kt │ │ │ │ ├── DanmakuType.kt │ │ │ │ ├── PlayMode.kt │ │ │ │ ├── RequestState.kt │ │ │ │ ├── Resolution.kt │ │ │ │ ├── VideoAspectRatio.kt │ │ │ │ ├── VideoCodec.kt │ │ │ │ ├── VideoListItem.kt │ │ │ │ ├── VideoPlayerClosedCaptionMenuItem.kt │ │ │ │ ├── VideoPlayerDanmakuMenuItem.kt │ │ │ │ ├── VideoPlayerData.kt │ │ │ │ ├── VideoPlayerMenuNavItem.kt │ │ │ │ ├── VideoPlayerOthersMenuItem.kt │ │ │ │ └── VideoPlayerPictureMenuItem.kt │ │ │ ├── seekbar/ │ │ │ │ ├── SeekBar.kt │ │ │ │ ├── SeekBarThumb.kt │ │ │ │ └── SeekMoveState.kt │ │ │ └── util/ │ │ │ ├── DanmakuMaskModifiers.kt │ │ │ └── VideoShotExtends.kt │ │ └── res/ │ │ └── values/ │ │ └── strings.xml │ └── tv/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── dev/ │ └── aaa1115910/ │ └── bv/ │ └── player/ │ └── tv/ │ ├── BvPlayer.kt │ ├── SeekBar.kt │ └── controller/ │ ├── BottomSubtitle.kt │ ├── ControllerVideoInfo.kt │ ├── MenuController.kt │ ├── PlayStateTips.kt │ ├── SeekController.kt │ ├── SkipTip.kt │ ├── VideoListController.kt │ ├── VideoPlayerController.kt │ ├── VideoShot.kt │ └── playermenu/ │ ├── ClosedCaptionMenu.kt │ ├── DanmakuMenu.kt │ ├── MenuNav.kt │ ├── OthersMenu.kt │ ├── PictureMenu.kt │ └── component/ │ ├── CheckBoxMenuList.kt │ ├── MenuListItem.kt │ ├── RadioMenuList.kt │ └── StepLessMenuItem.kt ├── settings.gradle.kts ├── symbols/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── dev/ │ └── aaa1115910/ │ └── symbols/ │ └── Symbols.kt └── utils/ ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src/ └── main/ ├── AndroidManifest.xml └── kotlin/ └── dev/ └── aaa1115910/ └── bv/ └── util/ ├── FirebaseUtil.kt ├── FocusRequesterExtends.kt ├── KLoggerExtends.kt ├── KeyEventExtends.kt ├── LongExtends.kt ├── SnapshotStateListExtends.kt ├── Timer.kt ├── ToastExtends.kt ├── createCustomInitialFocusRestorerModifiers.kt └── ifElse.kt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug 报告 description: 创建 Bug 报告以帮助开发者改进 title: "以简单的一段字概括你所遇到的问题" body: - type: markdown attributes: value: | ## 反馈须知 - 请务必完整填写下面的内容,如果缺少必要的信息,将无法解决任何问题 - 一个 issue 请只反馈一个 bug 或功能建议,一次性反馈多个不同的问题或建议或将会被直接关闭 - 注意你的标题,以简单的一段字概括你所遇到的问题。不要使用无意义内容或全部复制粘贴 - 该项目不为任何旧版本提供维护支持,请务必确认已更新到最新版本 - 应用仅支持系统硬件解码,如遇到播放卡顿或无法播放,请先检查设备芯片性能以及编码支持情况 - type: textarea id: description validations: required: true attributes: label: Bug 描述 description: 请简短地描述你遇到的问题 - type: textarea id: steps validations: required: true attributes: label: 复现问题的步骤 render: plain text description: 请提供复现问题的步骤,如果不能,请写明原因 placeholder: | 示例步骤: 1. 进入 '...' 2. 点击 '....' 3. 滚动到 '....' 4. 出现问题 - type: textarea id: expected-behavior validations: required: true attributes: label: 预期行为 description: 简要描述你希望看到什么样的结果 - type: textarea id: screenshots attributes: label: 截图 description: 如果可以,提交截图更有助于我们分析问题 - type: dropdown id: app-version-confirm-use-latest validations: required: true attributes: label: 请确认已更新到如下所示的版本 description: | ![GitHub Release Pre-Release](https://img.shields.io/endpoint?url=https%3A%2F%2Fbadge.versions.bv.aaa1115910.dev%2Fgithub%3Fprerelease%3Dtrue) options: - '我正在使用旧版本' - '已更新到当前最新 Alpha 版' - type: input id: app-version validations: required: true attributes: label: 当前版本号 placeholder: 0.0.1.r29.a6d7ecb.release (或使用缩写例如 r29) - type: input id: android-version validations: required: true attributes: label: Android 版本 placeholder: Android 13 - type: input id: device-info attributes: label: 设备厂商及型号 placeholder: Sony - BRAVIA XR MASTER SERIES Z9K - type: input id: video attributes: label: 遇到问题的视频 avid 或 bvid placeholder: av170001 - type: textarea id: additional-logs attributes: label: 相关日志 description: | 你可以在 `设置` > `更多设置` > `查看日志` 中查看已保存的日志,扫码即可下载获取(在同一网络环境下) 在日志列表中可找到自动生成的崩溃日志,或可在功能遇到问题(例如加载失败)后手动创建日志文件 上传文件时请务必等待文件上传完成后再提交 issue - type: textarea id: additional-content attributes: label: 附加信息 description: 添加你认为有必要的信息,例如出现问题的相关视频等等 ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 功能需求 description: 为项目提供建议 title: "以简单的一段字概括你的建议" body: - type: markdown attributes: value: | ## 反馈须知 - 请务必完整填写下面的内容,如果缺少必要的信息,将无法解决任何问题 - 一个 issue 请只反馈一个 bug 或功能建议,一次性反馈多个不同的问题或建议或将会被直接关闭 - 注意你的标题,以简单的一段字概括你所遇到的问题。不要使用无意义内容或全部复制粘贴 - 提出建议也不一定会做,这不可能是万能的许愿机,如果自己确实想要,建议 fork 项目自己实现 - 该项目不为任何旧版本提供维护支持,请务必确认已更新到最新版本 - type: textarea id: problem-description validations: required: true attributes: label: 问题描述 description: 描述你想要解决的问题或者功能的使用场景 placeholder: 请描述你遇到的问题或者链接到已存在的 issue - type: textarea id: solution-description validations: required: true attributes: label: 描述解决方案 description: 清晰简明地描述你所想要发生的事情,即解决方案 - type: textarea id: alternatives attributes: label: 描述备选方案 description: 解决该问题的备选方案 - type: textarea id: additional-info attributes: label: 附加信息 description: 添加你认为有必要的信息 ================================================ FILE: .github/workflows/alpha.yml ================================================ name: Alpha Build on: push: branches: - develop jobs: build-alpha: name: Build Alpha Apk runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@v4 with: ref: develop fetch-depth: 0 submodules: 'true' - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle to generate and submit dependency graphs uses: gradle/actions/setup-gradle@v3 with: dependency-graph: generate-and-submit - name: Write google-services.json env: DATA: ${{ secrets.GOOGLE_SERVICES_JSON }} run: echo $DATA > app/google-services.json - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Add signing properties env: SIGNING_PROPERTIES: ${{ secrets.SIGNING_PROPERTIES }} run: | echo ${{ secrets.SIGNING_PROPERTIES }} > encoded_signing_properties base64 -Dd -i encoded_signing_properties > signing.properties - name: Add jks file run: | echo ${{ secrets.SIGN_KEY }} > ./encoded_key base64 -Dd -i encoded_key > key.jks - name: Build apk run: ./gradlew assembleDefaultAlpha assembleDefaultDebug - name: Read alpha apk output metadata id: apk-meta-alpha uses: juliangruber/read-file-action@v1 with: path: app/build/outputs/apk/default/alpha/output-metadata.json - name: Read alpha debug apk output metadata id: apk-meta-alpha-debug uses: juliangruber/read-file-action@v1 with: path: app/build/outputs/apk/default/debug/output-metadata.json - name: Parse apk infos id: apk-infos run: | echo "alpha_info_version_code=${{ fromJson(steps.apk-meta-alpha.outputs.content).elements[0].versionCode }}" >> $GITHUB_ENV echo "alpha_info_version_name=${{ fromJson(steps.apk-meta-alpha.outputs.content).elements[0].versionName }}" >> $GITHUB_ENV echo "alpha_debug_info_version_code=${{ fromJson(steps.apk-meta-alpha-debug.outputs.content).elements[0].versionCode }}" >> $GITHUB_ENV echo "alpha_debug_info_version_name=${{ fromJson(steps.apk-meta-alpha-debug.outputs.content).elements[0].versionName }}" >> $GITHUB_ENV - name: Determine tag name id: tag_name run: echo "tag_name=alpha-r${{ env.alpha_info_version_code }}" >> $GITHUB_ENV - name: Get changelog id: changelog run: | { echo "changelog<> "$GITHUB_ENV" # upload artifacts alpha debug - name: Archive alpha debug build artifacts uses: actions/upload-artifact@v4 with: name: Alpha debug build artifact path: app/build/outputs/apk/default/debug/BV_${{ env.alpha_debug_info_version_code }}_${{ env.alpha_debug_info_version_name }}_default_universal.apk # upload artifacts alpha - name: Archive default alpha build mappings uses: actions/upload-artifact@v4 with: name: Alpha build mappings path: app/build/outputs/mapping/defaultAlpha - name: Archive alpha build artifacts uses: actions/upload-artifact@v4 with: name: Alpha build artifact path: app/build/outputs/apk/default/alpha/BV_${{ env.alpha_info_version_code }}_${{ env.alpha_info_version_name }}_default_universal.apk # zip mapping because softprops/action-gh-release can't upload folder - name: Zip mapping run: zip -rj mapping.zip app/build/outputs/mapping/defaultAlpha # upload to github release - name: Publish Pre-Release uses: softprops/action-gh-release@v2 with: files: | app/build/outputs/apk/default/debug/BV_${{ env.alpha_debug_info_version_code }}_${{ env.alpha_debug_info_version_name }}_default_universal.apk app/build/outputs/apk/default/alpha/BV_${{ env.alpha_info_version_code }}_${{ env.alpha_info_version_name }}_default_universal.apk mapping.zip tag_name: ${{ env.tag_name }} name: ${{ env.alpha_info_version_name }} prerelease: true body: ${{ env.changelog }} target_commitish: ${{ github.sha }} ================================================ FILE: .github/workflows/alpha_build_manually_without_sign.yml ================================================ name: Alpha Build Manually (Without signature) on: workflow_dispatch: inputs: google_services_json: description: "google-services.json (optional)" jobs: build-alpha: runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@v4 with: ref: develop fetch-depth: 0 submodules: 'true' - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' - name: Write google-services.json env: DATA: ${{ github.event.inputs.google_services_json }} run: echo $DATA > app/google-services.json - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build apk run: ./gradlew assembleDefaultAlpha assembleDefaultDebug - name: Read alpha apk output metadata id: apk-meta-alpha uses: juliangruber/read-file-action@v1 with: path: app/build/outputs/apk/default/alpha/output-metadata.json - name: Read alpha debug apk output metadata id: apk-meta-alpha-debug uses: juliangruber/read-file-action@v1 with: path: app/build/outputs/apk/default/debug/output-metadata.json - name: Parse apk infos id: apk-infos run: | echo "alpha_info_version_code=${{ fromJson(steps.apk-meta-alpha.outputs.content).elements[0].versionCode }}" >> $GITHUB_ENV echo "alpha_info_version_name=${{ fromJson(steps.apk-meta-alpha.outputs.content).elements[0].versionName }}" >> $GITHUB_ENV echo "alpha_debug_info_version_code=${{ fromJson(steps.apk-meta-alpha-debug.outputs.content).elements[0].versionCode }}" >> $GITHUB_ENV echo "alpha_debug_info_version_name=${{ fromJson(steps.apk-meta-alpha-debug.outputs.content).elements[0].versionName }}" >> $GITHUB_ENV # upload artifacts default-debug - name: Archive default debug build artifacts (universal) uses: actions/upload-artifact@v4 with: name: Default debug build artifact (universal) path: app/build/outputs/apk/default/debug/BV_${{ env.alpha_debug_info_version_code }}_${{ env.alpha_debug_info_version_name }}_default_universal.apk # upload artifacts default-alpha - name: Archive default alpha build mappings uses: actions/upload-artifact@v4 with: name: Default alpha build mappings path: app/build/outputs/mapping/defaultAlpha - name: Archive default alpha build artifacts (universal) uses: actions/upload-artifact@v4 with: name: Default alpha build artifact (universal) path: app/build/outputs/apk/default/alpha/BV_${{ env.alpha_info_version_code }}_${{ env.alpha_info_version_name }}_default_universal.apk ================================================ FILE: .github/workflows/auto_close_issues.yml ================================================ name: Check Issues on: issues: types: [ opened ] jobs: check: runs-on: ubuntu-latest steps: - if: contains(github.event.issue.title, '以简单的一段字概括' ) id: close-invalid-title name: Close Issue (invalid title) uses: actions-cool/issues-helper@v3 with: actions: 'add-labels,close-issue' token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} labels: '无效' close-reason: 'not_planned' - if: contains(github.event.issue.body, '我正在使用旧版本' ) id: close-old-version name: Close Issue (old version) uses: actions-cool/issues-helper@v3 with: actions: 'create-comment,close-issue' token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} close-reason: 'not_planned' body: 请先尝试使用当前最新 alpha 版本,如果问题依然存在再提交 issue ================================================ FILE: .github/workflows/close_inactive_issues.yml ================================================ name: Close inactive issues on: schedule: - cron: "30 1 * * *" jobs: close-issues: name: Close inactive issues runs-on: ubuntu-latest if: github.repository == 'ldm0206/bv' permissions: issues: write steps: - uses: actions/stale@v5 with: days-before-issue-stale: 60 days-before-issue-close: 14 days-before-pr-stale: -1 stale-issue-label: "过时" stale-issue-message: "该 issue 已过时,因为它已经超过 60 天没有任何活动" close-issue-message: "该 issue 已关闭,因为它在被标记为过时后 14 天依旧没有任何活动" repo-token: ${{ secrets.GITHUB_TOKEN }} exempt-issue-labels: "bug,新功能,优化,有待讨论,疑难杂症" ================================================ FILE: .github/workflows/features.yml ================================================ name: Feature Build on: push: branches: - 'feature/**' jobs: build-alpha: name: Build Feature Apk runs-on: macos-latest if: github.repository == 'ldm0206/bv' steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{ github.ref }} fetch-depth: 0 submodules: 'true' - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' - name: Write google-services.json env: DATA: ${{ secrets.GOOGLE_SERVICES_JSON }} run: echo $DATA > app/google-services.json - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Add signing properties env: SIGNING_PROPERTIES: ${{ secrets.SIGNING_PROPERTIES }} run: | echo ${{ secrets.SIGNING_PROPERTIES }} > encoded_signing_properties base64 -Dd -i encoded_signing_properties > signing.properties - name: Add jks file run: | echo ${{ secrets.SIGN_KEY }} > ./encoded_key base64 -Dd -i encoded_key > key.jks - name: Build apk run: ./gradlew assembleDefaultAlpha assembleDefaultDebug - name: Read alpha apk output metadata id: apk-meta-alpha uses: juliangruber/read-file-action@v1 with: path: app/build/outputs/apk/default/alpha/output-metadata.json - name: Read alpha debug apk output metadata id: apk-meta-alpha-debug uses: juliangruber/read-file-action@v1 with: path: app/build/outputs/apk/default/debug/output-metadata.json - name: Parse apk infos id: apk-infos run: | echo "alpha_info_version_code=${{ fromJson(steps.apk-meta-alpha.outputs.content).elements[0].versionCode }}" >> $GITHUB_ENV echo "alpha_info_version_name=${{ fromJson(steps.apk-meta-alpha.outputs.content).elements[0].versionName }}" >> $GITHUB_ENV echo "alpha_debug_info_version_code=${{ fromJson(steps.apk-meta-alpha-debug.outputs.content).elements[0].versionCode }}" >> $GITHUB_ENV echo "alpha_debug_info_version_name=${{ fromJson(steps.apk-meta-alpha-debug.outputs.content).elements[0].versionName }}" >> $GITHUB_ENV # upload artifacts alpha debug - name: Archive alpha debug build artifacts uses: actions/upload-artifact@v4 with: name: Alpha debug build artifact path: app/build/outputs/apk/default/debug/BV_${{ env.alpha_debug_info_version_code }}_${{ env.alpha_debug_info_version_name }}_default_universal.apk # upload artifacts alpha - name: Archive alpha build mappings uses: actions/upload-artifact@v4 with: name: Alpha build mappings path: app/build/outputs/mapping/defaultAlpha - name: Archive alpha build artifacts uses: actions/upload-artifact@v4 with: name: Alpha build artifact path: app/build/outputs/apk/default/alpha/BV_${{ env.alpha_info_version_code }}_${{ env.alpha_info_version_name }}_default_universal.apk ================================================ FILE: .github/workflows/release.yml ================================================ name: Release Build on: push: tags: - 'v[0-9]+.[0-9]+.[0-9]+' - 'v[0-9]+.[0-9]+.[0-9]+.[0-9]+' jobs: build-release: name: Build Release Apk runs-on: macos-latest if: github.repository == 'ldm0206/bv' steps: - name: Checkout uses: actions/checkout@v4 with: ref: master fetch-depth: 0 submodules: 'true' - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' - name: Write google-services.json env: DATA: ${{ secrets.GOOGLE_SERVICES_JSON }} run: echo $DATA > app/google-services.json - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Add signing properties env: SIGNING_PROPERTIES: ${{ secrets.SIGNING_PROPERTIES }} run: | echo ${{ secrets.SIGNING_PROPERTIES }} > encoded_signing_properties base64 -Dd -i encoded_signing_properties > signing.properties - name: Add jks file run: | echo ${{ secrets.SIGN_KEY }} > ./encoded_key base64 -Dd -i encoded_key > key.jks - name: Build apk run: ./gradlew assembleDefaultRelease assembleDefaultDebug - name: Read release apk output metadata id: apk-meta-release uses: juliangruber/read-file-action@v1 with: path: app/build/outputs/apk/default/release/output-metadata.json - name: Read debug apk output metadata id: apk-meta-release-debug uses: juliangruber/read-file-action@v1 with: path: app/build/outputs/apk/default/debug/output-metadata.json - name: Parse apk infos id: apk-infos run: | echo "release_info_version_code=${{ fromJson(steps.apk-meta-release.outputs.content).elements[0].versionCode }}" >> $GITHUB_ENV echo "release_info_version_name=${{ fromJson(steps.apk-meta-release.outputs.content).elements[0].versionName }}" >> $GITHUB_ENV echo "release_debug_info_version_code=${{ fromJson(steps.apk-meta-release-debug.outputs.content).elements[0].versionCode }}" >> $GITHUB_ENV echo "release_debug_info_version_name=${{ fromJson(steps.apk-meta-release-debug.outputs.content).elements[0].versionName }}" >> $GITHUB_ENV - name: Get changelog id: changelog run: | { echo "changelog<> "$GITHUB_ENV" # upload artifacts release debug - name: Archive release debug build artifacts uses: actions/upload-artifact@v4 with: name: Release debug build artifact path: app/build/outputs/apk/default/debug/BV_${{ env.release_debug_info_version_code }}_${{ env.release_debug_info_version_name }}_default_universal.apk # upload artifacts release - name: Archive release build mappings uses: actions/upload-artifact@v4 with: name: Release build mappings path: app/build/outputs/mapping/defaultRelease - name: Archive release build artifacts uses: actions/upload-artifact@v4 with: name: Release build artifact path: app/build/outputs/apk/default/release/BV_${{ env.release_info_version_code }}_${{ env.release_info_version_name }}_default_universal.apk # zip mapping because softprops/action-gh-release can't upload folder - name: Zip mapping run: zip -rj mapping.zip app/build/outputs/mapping/defaultRelease # upload to github release - name: Publish Release uses: softprops/action-gh-release@v2 with: files: | app/build/outputs/apk/default/debug/BV_${{ env.release_debug_info_version_code }}_${{ env.release_debug_info_version_name }}_default_universal.apk app/build/outputs/apk/default/release/BV_${{ env.release_info_version_code }}_${{ env.release_info_version_name }}_default_universal.apk mapping.zip tag_name: ${{ github.ref_name }} name: ${{ env.release_info_version_name }} prerelease: false body: ${{ env.changelog }} target_commitish: ${{ github.sha }} ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties .DS_Store /build /captures .externalNativeBuild .cxx /signing.properties /.idea/jarRepositories.xml /.idea/migrations.xml /.idea/codeStyles/ ================================================ FILE: .gitmodules ================================================ [submodule "libs"] path = libs url = https://github.com/aaa1115910/bv-libs.git ================================================ FILE: .idea/.gitignore ================================================ /shelf/ /workspace.xml /deploymentTargetDropDown.xml /gradle.xml /caches /libraries /modules.xml /navEditor.xml /assetWizardSettings.xml /misc.xml /compiler.xml /inspectionProfiles/Project_Default.xml /CamelCaseConfigNew.xml # GitHub Copilot persisted chat sessions /copilot/chatSessions ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: .idea/runConfigurations/Run_mobile.xml ================================================ ================================================ FILE: .idea/runConfigurations/Run_tv.xml ================================================ ================================================ FILE: .idea/runConfigurations/app.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 aaa1115910 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
# BV ~~Bug Video~~ [![GitHub Release Release](https://img.shields.io/endpoint?url=https%3A%2F%2Fbadge.versions.bv.aaa1115910.dev%2Fgithub%3Fprerelease%3Dfalse)](https://github.com/ldm0206/bv/releases?q=prerelease%3Afalse) [![GitHub Release Pre-Release](https://img.shields.io/endpoint?url=https%3A%2F%2Fbadge.versions.bv.aaa1115910.dev%2Fgithub%3Fprerelease%3Dtrue)](https://github.com/ldm0206/bv/releases?q=prerelease%3Atrue) [![Workflow Release](https://github.com/ldm0206/bv/actions/workflows/release.yml/badge.svg)](https://github.com/ldm0206/bv/actions/workflows/release.yml) [![Workflow Alpha](https://github.com/ldm0206/bv/actions/workflows/alpha.yml/badge.svg)](https://github.com/ldm0206/bv/actions/workflows/alpha.yml) [![Android Sdk Require](https://img.shields.io/badge/Android-6.0%2B-informational?logo=android)](https://developer.android.com/jetpack/androidx/versions#version-table) [![GitHub](https://img.shields.io/github/license/ldm0206/bv)](https://github.com/ldm0206/bv) **BV 无法在中国大陆地区内的智能电视上使用,如有相关使用需求请使用 [云视听小电视](https://app.bilibili.com)** **禁止在中国境内传播、宣传、分发 BV**
--- BV ~~(Bug Video)~~ 是一款 [哔哩哔哩](https://www.bilibili.com) 的第三方应用,适配 `Android 移动端` 和 `Android TV`,使用 `Jetpack Compose` 开发 **都是随心乱写的代码,能跑就行。** ## 特色 - :bug: 丰富多样的 Bug - :children_crossing: 反人类设计 - :zap: 卡卡卡卡卡 - :art: 异样审美 - :disappointed: 巨难用 ## 安装 ### Release - [Github Release](https://github.com/ldm0206/bv/releases?q=prerelease%3Afalse) ### Alpha - [Github Release](https://github.com/ldm0206/bv/releases?q=prerelease%3Atrue) ## License [MIT](LICENSE) © aaa1115910 ================================================ FILE: app/.gitignore ================================================ /build /google-services.json /release /r8Test /debug ================================================ FILE: app/build.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") import com.android.build.gradle.internal.api.ApkVariantOutputImpl import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension import java.io.FileInputStream import java.util.Properties plugins { alias(gradleLibs.plugins.android.application) alias(gradleLibs.plugins.compose.compiler) alias(gradleLibs.plugins.firebase.crashlytics) alias(gradleLibs.plugins.google.ksp) alias(gradleLibs.plugins.google.services) apply false alias(gradleLibs.plugins.kotlin.android) alias(gradleLibs.plugins.kotlin.serialization) } if (AppConfiguration.googleServicesAvailable) { apply(plugin = gradleLibs.plugins.google.services.get().pluginId) } val signingProp = file(project.rootProject.file("signing.properties")) android { signingConfigs { if (signingProp.exists()) { val properties = Properties().apply { load(FileInputStream(signingProp)) } create("key") { storeFile = rootProject.file(properties.getProperty("keystore.path")) storePassword = properties.getProperty("keystore.pwd") keyAlias = properties.getProperty("keystore.alias") keyPassword = properties.getProperty("keystore.alias_pwd") } } } namespace = AppConfiguration.appId compileSdk = AppConfiguration.compileSdk defaultConfig { applicationId = AppConfiguration.appId minSdk = AppConfiguration.minSdk targetSdk = AppConfiguration.targetSdk versionCode = AppConfiguration.versionCode versionName = AppConfiguration.versionName vectorDrawables { useSupportLibrary = true } } flavorDimensions.add("channel") productFlavors { create("lite") { dimension = "channel" } create("default") { dimension = "channel" } } buildTypes { release { isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) if (signingProp.exists()) signingConfig = signingConfigs.getByName("key") configure { mappingFileUploadEnabled = AppConfiguration.googleServicesAvailable } } debug { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) applicationIdSuffix = ".debug" configure { mappingFileUploadEnabled = false } } create("r8Test") { isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) applicationIdSuffix = ".r8test" if (signingProp.exists()) signingConfig = signingConfigs.getByName("key") configure { mappingFileUploadEnabled = false } } create("alpha") { isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) if (signingProp.exists()) signingConfig = signingConfigs.getByName("key") configure { mappingFileUploadEnabled = AppConfiguration.googleServicesAvailable } } } buildFeatures { compose = true //buildConfig = true } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "**/*.proto" } if (gradle.startParameter.taskNames.find { it.startsWith("assembleLite") } != null) { jniLibs { val vlcLibs = listOf("libvlc", "libc++_shared", "libvlcjni") val abis = listOf("x86_64", "x86", "arm64-v8a", "armeabi-v7a") vlcLibs.forEach { vlcLibName -> abis.forEach { abi -> excludes.add("lib/$abi/$vlcLibName.so") } } } } } /*splits { if (gradle.startParameter.taskNames.find { it.startsWith("assembleDefault") } != null) { abi { isEnable = true reset() include("x86_64", "x86", "arm64-v8a", "armeabi-v7a") isUniversalApk = true } } }*/ applicationVariants.configureEach { val variant = this outputs.configureEach { (this as ApkVariantOutputImpl).apply { val abi = this.filters.find { it.filterType == "ABI" }?.identifier ?: "universal" outputFileName = "BV_${AppConfiguration.versionCode}_${AppConfiguration.versionName}.${variant.buildType.name}_${variant.flavorName}_$abi.apk" versionNameOverride = "${variant.versionName}.${variant.buildType.name}" } } } } composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_build_reports") stabilityConfigurationFiles.addAll( layout.projectDirectory.file("compose_compiler_config.conf") ) } java { toolchain { languageVersion.set(JavaLanguageVersion.of(AppConfiguration.jdk)) } } dependencies { implementation(project(":app:mobile")) implementation(project(":app:tv")) } tasks.withType { useJUnitPlatform() } ================================================ FILE: app/compose_compiler_config.conf ================================================ kotlin.collections.* kotlin.time.Duration kotlinx.coroutines.CoroutineScope androidx.paging.compose.LazyPagingItems ================================================ FILE: app/mobile/.gitignore ================================================ /build ================================================ FILE: app/mobile/build.gradle.kts ================================================ plugins { alias(gradleLibs.plugins.android.library) alias(gradleLibs.plugins.compose.compiler) alias(gradleLibs.plugins.google.ksp) alias(gradleLibs.plugins.google.services) apply false alias(gradleLibs.plugins.kotlin.android) alias(gradleLibs.plugins.kotlin.serialization) } android { namespace = AppConfiguration.appId + ".mobile" compileSdk = AppConfiguration.compileSdk defaultConfig { minSdk = AppConfiguration.minSdk vectorDrawables { useSupportLibrary = true } } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } create("r8Test") { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } create("alpha") { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } buildFeatures { compose = true } lint { targetSdk = AppConfiguration.targetSdk } testOptions { targetSdk = AppConfiguration.targetSdk } } java { toolchain { languageVersion.set(JavaLanguageVersion.of(AppConfiguration.jdk)) } } dependencies { implementation(project(":app:shared")) } ================================================ FILE: app/mobile/consumer-rules.pro ================================================ ================================================ FILE: app/mobile/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: app/mobile/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/gallery/ImageGallery.kt ================================================ package com.origeek.imageViewer.gallery import androidx.annotation.FloatRange import androidx.annotation.IntRange import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.origeek.imageViewer.previewer.DEFAULT_ITEM_SPACE import com.origeek.imageViewer.viewer.ImageViewer import com.origeek.imageViewer.viewer.ImageViewerState import com.origeek.imageViewer.viewer.rememberViewerState import kotlinx.coroutines.launch /** * @program: ImageViewer * * @description: * * @author: JVZIYAOYAO * * @create: 2022-10-10 11:50 **/ /** * gallery手势对象 */ class GalleryGestureScope( // 点击事件 var onTap: () -> Unit = {}, // 双击事件 var onDoubleTap: () -> Boolean = { false }, // 长按事件 var onLongPress: () -> Unit = {}, ) /** * gallery图层对象 */ class GalleryLayerScope( // viewer图层 var viewerContainer: @Composable ( page: Int, viewerState: ImageViewerState, viewer: @Composable () -> Unit ) -> Unit = { _, _, viewer -> viewer() }, // 背景图层 var background: @Composable ((Int) -> Unit) = {}, // 前景图层 var foreground: @Composable ((Int) -> Unit) = {}, ) /** * gallery状态 */ open class ImageGalleryState( val pagerState: ImagePagerState, ) { /** * 当前viewer的状态 */ var imageViewerState by mutableStateOf(null) internal set /** * 当前页码 */ val currentPage: Int get() = pagerState.currentPage /** * 目标页码 */ val targetPage: Int get() = pagerState.targetPage /** * interactionSource */ val interactionSource: InteractionSource get() = pagerState.interactionSource /** * 滚动到指定页面 * @param page Int * @param pageOffset Float */ suspend fun scrollToPage( @IntRange(from = 0) page: Int, @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f, ) = pagerState.scrollToPage(page, pageOffset) /** * 动画滚动到指定页面 * @param page Int * @param pageOffset Float */ suspend fun animateScrollToPage( @IntRange(from = 0) page: Int, @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f, ) = pagerState.animateScrollToPage(page, pageOffset) } /** * 记录gallery状态 */ @Composable fun rememberImageGalleryState( @IntRange(from = 0) initialPage: Int = 0, pageCount: () -> Int, ): ImageGalleryState { val imagePagerState = rememberImagePagerState(initialPage, pageCount) return remember { ImageGalleryState(imagePagerState) } } /** * 图片gallery,基于Pager实现的一个图片查看列表组件 */ @Composable fun ImageGallery( // 编辑参数 modifier: Modifier = Modifier, // gallery状态 state: ImageGalleryState, // 图片加载器 imageLoader: @Composable (Int) -> Any?, // 每张图片之间的间隔 itemSpacing: Dp = DEFAULT_ITEM_SPACE, // 检测手势 detectGesture: GalleryGestureScope.() -> Unit = {}, // gallery图层 galleryLayer: GalleryLayerScope.() -> Unit = {}, ) { // require(count >= 0) { "imageCount must be >= 0" } val scope = rememberCoroutineScope() // 手势相关 val galleryGestureScope = remember { GalleryGestureScope() } detectGesture.invoke(galleryGestureScope) // 图层相关 val galleryLayerScope = remember { GalleryLayerScope() } galleryLayer.invoke(galleryLayerScope) // 确保不会越界 val currentPage = state.currentPage Box( modifier = modifier .fillMaxSize() ) { galleryLayerScope.background(currentPage) ImageHorizonPager( state = state.pagerState, modifier = Modifier .fillMaxSize(), itemSpacing = itemSpacing, ) { page -> val imageState = rememberViewerState() LaunchedEffect(key1 = currentPage) { if (currentPage != page) imageState.reset() if (currentPage == page) { state.imageViewerState = imageState } } galleryLayerScope.viewerContainer(page, imageState) { Box( modifier = Modifier .fillMaxSize(), ) { key(page) { ImageViewer( modifier = Modifier.fillMaxSize(), model = imageLoader(page), state = imageState, boundClip = false, detectGesture = { this.onTap = { galleryGestureScope.onTap() } this.onDoubleTap = { val consumed = galleryGestureScope.onDoubleTap() if (!consumed) scope.launch { imageState.toggleScale(it) } } this.onLongPress = { galleryGestureScope.onLongPress() } }, ) } } } } galleryLayerScope.foreground(currentPage) } } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/gallery/ImagePager.kt ================================================ package com.origeek.imageViewer.gallery import androidx.annotation.FloatRange import androidx.annotation.IntRange import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** * @program: ImageViewer * * @description: * * @author: JVZIYAOYAO * * @create: 2022-10-05 21:41 **/ /** * 基于HorizonPager封装的pager组件 */ open class ImagePagerState @OptIn(ExperimentalFoundationApi::class) constructor( val pagerState: PagerState, ) { /** * 当前页码 */ @OptIn(ExperimentalFoundationApi::class) val currentPage: Int get() = pagerState.currentPage /** * 目标页码 */ @OptIn(ExperimentalFoundationApi::class) val targetPage: Int get() = pagerState.targetPage /** * interactionSource */ @OptIn(ExperimentalFoundationApi::class) val interactionSource: InteractionSource get() = pagerState.interactionSource /** * 滚动到指定页面 */ @OptIn(ExperimentalFoundationApi::class) suspend fun scrollToPage( // 指定的页码 @IntRange(from = 0) page: Int, // 滚动偏移量 @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f, ) = pagerState.scrollToPage(page, pageOffset) /** * 动画滚动到指定页面 */ @OptIn(ExperimentalFoundationApi::class) suspend fun animateScrollToPage( // 指定的页码 @IntRange(from = 0) page: Int, // 滚动偏移量 @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f, ) = pagerState.animateScrollToPage(page, pageOffset) } /** * 记录pager状态 */ @OptIn(ExperimentalFoundationApi::class) @Composable fun rememberImagePagerState( // 默认显示的页码 @IntRange(from = 0) initialPage: Int = 0, pageCount: () -> Int, ): ImagePagerState { val pageState = rememberPagerState(initialPage = initialPage, pageCount = pageCount) return remember { ImagePagerState(pageState) } } /** * pager组件 */ @OptIn(ExperimentalFoundationApi::class) @Composable fun ImageHorizonPager( // 编辑参数 modifier: Modifier = Modifier, // pager状态 state: ImagePagerState, // 每个item之间的间隔 itemSpacing: Dp = 0.dp, // 页面内容 content: @Composable (page: Int) -> Unit, ) { HorizontalPager( state = state.pagerState, modifier = modifier, pageSpacing = itemSpacing, ) { page -> content(page) } } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/previewer/ImagePreviewer.kt ================================================ package com.origeek.imageViewer.previewer import androidx.annotation.IntRange import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.origeek.imageViewer.gallery.GalleryGestureScope import com.origeek.imageViewer.gallery.ImageGallery import com.origeek.imageViewer.gallery.ImageGalleryState import com.origeek.imageViewer.gallery.rememberImageGalleryState import com.origeek.imageViewer.viewer.ImageViewerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope // 预览的默认的背景颜色 val DEEP_DARK_FANTASY = Color(0xFF000000) // 图片间的默认间隔 val DEFAULT_ITEM_SPACE = 12.dp // 比较轻柔的动画窗格 val DEFAULT_SOFT_ANIMATION_SPEC = tween(320) /** * 预览的默认背景 */ @Composable fun DefaultPreviewerBackground() { Box( modifier = Modifier .background(DEEP_DARK_FANTASY) .fillMaxSize() ) } /** * 预览组件的状态 */ class ImagePreviewerState( // 协程作用域 scope: CoroutineScope = MainScope(), // 默认动画窗格 defaultAnimationSpec: AnimationSpec = DEFAULT_SOFT_ANIMATION_SPEC, // 预览状态 galleryState: ImageGalleryState, ) : PreviewerVerticalDragState(scope, defaultAnimationSpec, galleryState = galleryState) { companion object { fun getSaver(galleryState: ImageGalleryState): Saver { return mapSaver( save = { mapOf( it::currentPage.name to it.currentPage, it::animateContainerVisibleState.name to it.animateContainerVisibleState.currentState, it::uiAlpha.name to it.uiAlpha.value, it::visible.name to it.visible, ) }, restore = { val previewerState = ImagePreviewerState(galleryState = galleryState) previewerState.animateContainerVisibleState = MutableTransitionState(it[ImagePreviewerState::animateContainerVisibleState.name] as Boolean) previewerState.uiAlpha = Animatable(it[ImagePreviewerState::uiAlpha.name] as Float) previewerState.visible = it[ImagePreviewerState::visible.name] as Boolean previewerState } ) } } } /** * 记录预览组件状态 */ @Composable fun rememberPreviewerState( // 协程作用域 scope: CoroutineScope = rememberCoroutineScope(), // 动画窗格 animationSpec: AnimationSpec = DEFAULT_SOFT_ANIMATION_SPEC, // 开启垂直手势的类型 verticalDragType: VerticalDragType = VerticalDragType.None, // 初始页码 @IntRange(from = 0) initialPage: Int = 0, // 获取页数 pageCount: () -> Int, // 提供给组件用于获取key的方法 getKey: ((Int) -> Any)? = null, ): ImagePreviewerState { val galleryState = rememberImageGalleryState(initialPage, pageCount) val imagePreviewerState = rememberSaveable(saver = ImagePreviewerState.getSaver(galleryState)) { ImagePreviewerState(galleryState = galleryState) } imagePreviewerState.scope = scope imagePreviewerState.getKey = getKey imagePreviewerState.defaultAnimationSpec = animationSpec imagePreviewerState.verticalDragType = verticalDragType return imagePreviewerState } /** * 默认的弹出预览时的动画效果 */ val DEFAULT_PREVIEWER_ENTER_TRANSITION = scaleIn(tween(180)) + fadeIn(tween(240)) /** * 默认的关闭预览时的动画效果 */ val DEFAULT_PREVIEWER_EXIT_TRANSITION = scaleOut(tween(320)) + fadeOut(tween(240)) // 默认淡入淡出动画窗格 val DEFAULT_CROSS_FADE_ANIMATE_SPEC: AnimationSpec = tween(80) // 加载占位默认的进入动画 val DEFAULT_PLACEHOLDER_ENTER_TRANSITION = fadeIn(tween(200)) // 加载占位默认的退出动画 val DEFAULT_PLACEHOLDER_EXIT_TRANSITION = fadeOut(tween(200)) // 默认的加载占位 @OptIn(ExperimentalMaterial3ExpressiveApi::class) val DEFAULT_PREVIEWER_PLACEHOLDER_CONTENT = @Composable { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { LoadingIndicator() //CircularProgressIndicator(color = Color.White.copy(0.2F)) } } // 加载时的占位内容 class PreviewerPlaceholder( // 进入动画 var enterTransition: EnterTransition = DEFAULT_PLACEHOLDER_ENTER_TRANSITION, // 退出动画 var exitTransition: ExitTransition = DEFAULT_PLACEHOLDER_EXIT_TRANSITION, // 占位的内容 var content: @Composable () -> Unit = DEFAULT_PREVIEWER_PLACEHOLDER_CONTENT, ) /** * 预览图层对象 */ class PreviewerLayerScope( // 包裹viewer的容器图层 var viewerContainer: @Composable ( page: Int, viewerState: ImageViewerState, viewer: @Composable () -> Unit ) -> Unit = { _, _, viewer -> viewer() }, // 背景图层 var background: @Composable ((page: Int) -> Unit) = { _ -> DefaultPreviewerBackground() }, // 前景图层 var foreground: @Composable ((page: Int) -> Unit) = { _ -> }, // 加载时的占位内容 var placeholder: PreviewerPlaceholder = PreviewerPlaceholder() ) /** * 图片预览组件 */ @Composable fun ImagePreviewer( // 编辑参数 modifier: Modifier = Modifier, // 状态对象 state: ImagePreviewerState, // 图片加载器 imageLoader: @Composable (Int) -> Any?, // 图片间的间隔 itemSpacing: Dp = DEFAULT_ITEM_SPACE, // 进入动画 enter: EnterTransition = DEFAULT_PREVIEWER_ENTER_TRANSITION, // 退出动画 exit: ExitTransition = DEFAULT_PREVIEWER_EXIT_TRANSITION, // 检测手势 detectGesture: GalleryGestureScope.() -> Unit = {}, // 自定义previewer的各个图层 previewerLayer: PreviewerLayerScope.() -> Unit = {}, ) { state.apply { // 图层相关 val layerScope = remember { PreviewerLayerScope() } previewerLayer.invoke(layerScope) LaunchedEffect( key1 = animateContainerVisibleState, key2 = animateContainerVisibleState.currentState ) { onAnimateContainerStateChanged() } AnimatedVisibility( modifier = Modifier.fillMaxSize(), visibleState = animateContainerVisibleState, enter = enterTransition ?: enter, exit = exitTransition ?: exit, ) { Box( modifier = Modifier .fillMaxSize() .pointerInput(getKey) { verticalDrag(this) } ) { @Composable fun UIContainer(content: @Composable () -> Unit) { Box( modifier = Modifier .fillMaxSize() .alpha(uiAlpha.value) ) { content() } } ImageGallery( modifier = modifier.fillMaxSize(), state = galleryState, imageLoader = imageLoader, itemSpacing = itemSpacing, detectGesture = detectGesture, galleryLayer = { this.viewerContainer = { page, viewerState, viewer -> layerScope.viewerContainer(page, viewerState) { val viewerContainerState = rememberViewerContainerState( viewerState = viewerState, animationSpec = defaultAnimationSpec ) LaunchedEffect(key1 = currentPage) { if (currentPage == page) { state.viewerContainerState = viewerContainerState } } ImageViewerContainer( modifier = Modifier.alpha(viewerAlpha.value), containerState = viewerContainerState, placeholder = layerScope.placeholder, viewer = viewer, ) } } this.background = { UIContainer { layerScope.background(it) } } this.foreground = { UIContainer { layerScope.foreground(it) } } }, ) if (!visible) Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTapGestures { } }) { } } } ticket.Next() } } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/previewer/ImageTransform.kt ================================================ package com.origeek.imageViewer.previewer import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine /** * @program: ImageViewer * * @description: * * @author: JVZIYAOYAO * * @create: 2022-09-22 10:13 **/ // 用于操作transformItemStateMap的锁对象 internal val imageTransformMutex = Mutex() // 用于缓存界面上的transformItemState internal val transformItemStateMap = mutableStateMapOf() @Composable fun TransformImageView( modifier: Modifier = Modifier, painter: Painter, key: Any, itemState: TransformItemState = rememberTransformItemState(), previewerState: ImagePreviewerState, ) { TransformImageView( modifier = modifier, key = key, itemState = itemState, contentState = previewerState.transformState, ) { itemKey -> key(itemKey) { LaunchedEffect(key1 = painter.intrinsicSize) { if (painter.intrinsicSize.isSpecified) { itemState.intrinsicSize = painter.intrinsicSize } } Image( modifier = Modifier.fillMaxSize(), painter = painter, contentDescription = null, contentScale = ContentScale.Crop, ) } } } @Composable fun TransformImageView( modifier: Modifier = Modifier, bitmap: ImageBitmap, key: Any, itemState: TransformItemState = rememberTransformItemState(), previewerState: ImagePreviewerState, ) { TransformImageView( modifier = modifier, key = key, itemState = itemState, previewerState = previewerState, ) { itemState.intrinsicSize = Size( bitmap.width.toFloat(), bitmap.height.toFloat() ) Image( modifier = Modifier.fillMaxSize(), bitmap = bitmap, contentDescription = null, contentScale = ContentScale.Crop, ) } } @Composable fun TransformImageView( modifier: Modifier = Modifier, imageVector: ImageVector, key: Any, itemState: TransformItemState = rememberTransformItemState(), previewerState: ImagePreviewerState, ) { TransformImageView( modifier = modifier, key = key, itemState = itemState, previewerState = previewerState, ) { LocalDensity.current.run { itemState.intrinsicSize = Size( imageVector.defaultWidth.toPx(), imageVector.defaultHeight.toPx(), ) } Image( modifier = Modifier.fillMaxSize(), imageVector = imageVector, contentDescription = null, contentScale = ContentScale.Crop, ) } } @Composable fun TransformImageView( modifier: Modifier = Modifier, key: Any, itemState: TransformItemState = rememberTransformItemState(), previewerState: ImagePreviewerState, content: @Composable (Any) -> Unit, ) = TransformImageView(modifier, key, itemState, previewerState.transformState, content) @Composable fun TransformImageView( modifier: Modifier = Modifier, key: Any, itemState: TransformItemState = rememberTransformItemState(), contentState: TransformContentState? = rememberTransformContentState(), content: @Composable (Any) -> Unit, ) { TransformItemView( modifier = modifier, key = key, itemState = itemState, contentState = contentState, ) { content(key) } } @Composable fun TransformItemView( modifier: Modifier = Modifier, key: Any, itemState: TransformItemState = rememberTransformItemState(), contentState: TransformContentState?, content: @Composable (Any) -> Unit, ) { val scope = rememberCoroutineScope() itemState.key = key itemState.blockCompose = content DisposableEffect(key) { // 这个composable加载时添加到map scope.launch { itemState.addItem() } onDispose { // composable退出时从map移除 itemState.removeItem() } } Box( modifier = modifier .onGloballyPositioned { itemState.onPositionChange( position = it.positionInRoot(), size = it.size, ) } .fillMaxSize() ) { if ( contentState?.itemState != itemState || !contentState.onAction ) { itemState.blockCompose(key) } } } @Composable fun TransformContentView( transformContentState: TransformContentState = rememberTransformContentState(), ) { Box( modifier = Modifier .fillMaxSize() .onGloballyPositioned { transformContentState.containerSize = it.size transformContentState.containerOffset = it.positionInRoot() }, ) { if ( transformContentState.srcCompose != null && transformContentState.onAction ) { Box( modifier = Modifier .offset( x = LocalDensity.current.run { (transformContentState.offsetX.value).toDp() }, y = LocalDensity.current.run { (transformContentState.offsetY.value).toDp() }, ) .size( width = LocalDensity.current.run { transformContentState.displayWidth.value.toDp() }, height = LocalDensity.current.run { transformContentState.displayHeight.value.toDp() }, ) .graphicsLayer { transformOrigin = TransformOrigin(0F, 0F) scaleX = transformContentState.graphicScaleX.value scaleY = transformContentState.graphicScaleY.value }, ) { transformContentState.srcCompose!!(transformContentState.itemState?.key ?: Unit) } } } } class TransformContentState( // 协程作用域 var scope: CoroutineScope = MainScope(), // 默认动画窗格 var defaultAnimationSpec: AnimationSpec = DEFAULT_SOFT_ANIMATION_SPEC ) { var itemState: TransformItemState? by mutableStateOf(null) val intrinsicSize: Size get() = itemState?.intrinsicSize ?: Size.Zero val intrinsicRatio: Float get() { if (intrinsicSize.height == 0F) return 1F return intrinsicSize.width.div(intrinsicSize.height) } val srcPosition: Offset get() { val offset = itemState?.blockPosition ?: Offset.Zero return offset.copy(x = offset.x - containerOffset.x, y = offset.y - containerOffset.y) } val srcSize: IntSize get() = itemState?.blockSize ?: IntSize.Zero val srcCompose: (@Composable (Any) -> Unit)? get() = itemState?.blockCompose var onAction by mutableStateOf(false) var onActionTarget by mutableStateOf(null) var displayWidth = Animatable(0F) var displayHeight = Animatable(0F) var graphicScaleX = Animatable(1F) var graphicScaleY = Animatable(1F) var offsetX = Animatable(0F) var offsetY = Animatable(0F) var containerOffset by mutableStateOf(Offset.Zero) private var containerSizeState = mutableStateOf(IntSize.Zero) var containerSize: IntSize get() = containerSizeState.value set(value) { containerSizeState.value = value if (value.width != 0 && value.height != 0) { scope.launch { specifierSizeFlow.emit(true) } } } var specifierSizeFlow = MutableStateFlow(false) val containerRatio: Float get() { if (containerSize.height == 0) return 1F return containerSize.width.toFloat().div(containerSize.height) } val widthFixed: Boolean get() = intrinsicRatio > containerRatio val fitSize: Size get() { return if (intrinsicRatio > containerRatio) { // 宽度一致 val uW = containerSize.width val uH = uW / intrinsicRatio Size(uW.toFloat(), uH) } else { // 高度一致 val uH = containerSize.height val uW = uH * intrinsicRatio Size(uW, uH.toFloat()) } } val fitOffsetX: Float get() { return (containerSize.width - fitSize.width).div(2) } val fitOffsetY: Float get() { return (containerSize.height - fitSize.height).div(2) } val fitScale: Float get() { return fitSize.width.div(displayRatioSize.width) } val displayRatioSize: Size get() { return Size(width = srcSize.width.toFloat(), height = srcSize.width.div(intrinsicRatio)) } val realSize: Size get() { return Size( width = displayWidth.value * graphicScaleX.value, height = displayHeight.value * graphicScaleY.value, ) } suspend fun awaitContainerSizeSpecifier() { specifierSizeFlow.takeWhile { !it }.collect {} } fun findTransformItem(key: Any) = transformItemStateMap[key] fun clearTransformItems() = transformItemStateMap.clear() fun setEnterState() { onAction = true onActionTarget = null } fun setExitState() { onAction = false onActionTarget = null } suspend fun notifyEnterChanged() { scope.launch { listOf( scope.async { displayWidth.snapTo(displayRatioSize.width) }, scope.async { displayHeight.snapTo(displayRatioSize.height) }, scope.async { graphicScaleX.snapTo(fitScale) }, scope.async { graphicScaleY.snapTo(fitScale) }, scope.async { offsetX.snapTo(fitOffsetX) }, scope.async { offsetY.snapTo(fitOffsetY) }, ).awaitAll() } } suspend fun exitTransform( animationSpec: AnimationSpec? = null ) = suspendCoroutine { c -> val currentAnimateSpec = animationSpec ?: defaultAnimationSpec scope.launch { listOf( scope.async { displayWidth.animateTo(srcSize.width.toFloat(), currentAnimateSpec) }, scope.async { displayHeight.animateTo(srcSize.height.toFloat(), currentAnimateSpec) }, scope.async { graphicScaleX.animateTo(1F, currentAnimateSpec) }, scope.async { graphicScaleY.animateTo(1F, currentAnimateSpec) }, scope.async { offsetX.animateTo(srcPosition.x, currentAnimateSpec) }, scope.async { offsetY.animateTo(srcPosition.y, currentAnimateSpec) }, ).awaitAll() onAction = false onActionTarget = null c.resume(Unit) } } suspend fun enterTransform( itemState: TransformItemState, animationSpec: AnimationSpec? = null ) = suspendCoroutine { c -> val currentAnimationSpec = animationSpec ?: defaultAnimationSpec this.itemState = itemState displayWidth = Animatable(srcSize.width.toFloat()) displayHeight = Animatable(srcSize.height.toFloat()) graphicScaleX = Animatable(1F) graphicScaleY = Animatable(1F) offsetX = Animatable(srcPosition.x) offsetY = Animatable(srcPosition.y) onActionTarget = true onAction = true scope.launch { reset(currentAnimationSpec) c.resume(Unit) onActionTarget = null } } suspend fun reset(animationSpec: AnimationSpec? = null) { val currentAnimationSpec = animationSpec ?: defaultAnimationSpec listOf( scope.async { displayWidth.animateTo(displayRatioSize.width, currentAnimationSpec) }, scope.async { displayHeight.animateTo(displayRatioSize.height, currentAnimationSpec) }, scope.async { graphicScaleX.animateTo(fitScale, currentAnimationSpec) }, scope.async { graphicScaleY.animateTo(fitScale, currentAnimationSpec) }, scope.async { offsetX.animateTo(fitOffsetX, currentAnimationSpec) }, scope.async { offsetY.animateTo(fitOffsetY, currentAnimationSpec) }, ).awaitAll() } companion object { val Saver: Saver = listSaver( save = { listOf( it.onAction, ) }, restore = { val transformContentState = TransformContentState() transformContentState.onAction = it[0] as Boolean transformContentState } ) } } @Composable fun rememberTransformContentState( scope: CoroutineScope = rememberCoroutineScope(), animationSpec: AnimationSpec = DEFAULT_SOFT_ANIMATION_SPEC ): TransformContentState { val transformContentState = rememberSaveable(saver = TransformContentState.Saver) { TransformContentState() } transformContentState.scope = scope transformContentState.defaultAnimationSpec = animationSpec return transformContentState } class TransformItemState( var key: Any = Unit, var blockCompose: (@Composable (Any) -> Unit) = {}, var scope: CoroutineScope, var blockPosition: Offset = Offset.Zero, var blockSize: IntSize = IntSize.Zero, var intrinsicSize: Size? = null, var checkInBound: (TransformItemState.() -> Boolean)? = null, ) { private fun checkItemInMap() { if (checkInBound == null) return if (checkInBound!!.invoke(this)) { addItem() } else { removeItem() } } /** * 位置和大小发生变化时 * @param position Offset * @param size IntSize */ internal fun onPositionChange(position: Offset, size: IntSize) { blockPosition = position blockSize = size scope.launch { checkItemInMap() } } /** * 判断item是否在所需范围内,返回true,则添加该item到map,返回false则移除 * @param checkInBound Function0 */ fun checkIfInBound(checkInBound: () -> Boolean) { if (checkInBound()) { addItem() } else { removeItem() } } /** * 添加item到map上 * @param key Any? */ fun addItem(key: Any? = null) { val currentKey = key ?: this.key ?: return if (checkInBound != null) return synchronized(imageTransformMutex) { transformItemStateMap[currentKey] = this } } /** * 从map上移除item * @param key Any? */ fun removeItem(key: Any? = null) { synchronized(imageTransformMutex) { val currentKey = key ?: this.key ?: return if (checkInBound != null) return transformItemStateMap.remove(currentKey) } } } @Composable fun rememberTransformItemState( scope: CoroutineScope = rememberCoroutineScope(), checkInBound: (TransformItemState.() -> Boolean)? = null, ): TransformItemState { return remember { TransformItemState(scope = scope, checkInBound = checkInBound) } } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/previewer/ImageViewerContainer.kt ================================================ package com.origeek.imageViewer.previewer import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.IntSize import com.origeek.imageViewer.viewer.ImageViewerState import com.origeek.imageViewer.viewer.rememberViewerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.withContext /** * @program: ImageViewer * * @description: * * @author: JVZIYAOYAO * * @create: 2022-10-17 14:45 **/ internal class ViewerContainerState( // 协程作用域 var scope: CoroutineScope = MainScope(), // 转换图层的状态 var transformState: TransformContentState = TransformContentState(), // viewer的状态 var imageViewerState: ImageViewerState = ImageViewerState(), // 默认动画窗格 var defaultAnimationSpec: AnimationSpec = DEFAULT_SOFT_ANIMATION_SPEC ) { /** * +-------------------+ * INTERNAL * +-------------------+ */ // 转换图层transformContent透明度 internal var transformContentAlpha = Animatable(0F) // viewer容器的透明度 internal var viewerContainerAlpha = Animatable(1F) // 是否允许界面显示loading internal var allowLoading by mutableStateOf(true) // 打开图片后到加载成功过程的协程任务 internal var openTransformJob: Deferred? = null /** * 取消打开动作 */ internal fun cancelOpenTransform() { openTransformJob?.cancel() openTransformJob = null } /** * 等待挂载成功 */ internal suspend fun awaitOpenTransform() { // 这里需要等待viewer挂载,显示loading界面 openTransformJob = scope.async { // 等待viewer加载 awaitViewerLoading() // viewer加载成功后显示viewer transformSnapToViewer(true) } openTransformJob?.await() openTransformJob = null } /** * 等待viewer挂载成功 */ internal suspend fun awaitViewerLoading() { imageViewerState.mountedFlow.apply { withContext(Dispatchers.Default) { takeWhile { !it }.collect() } } } /** * 转换图层转viewer图层,true显示viewer,false显示转换图层 * @param isViewer Boolean */ internal suspend fun transformSnapToViewer(isViewer: Boolean) { if (isViewer) { transformContentAlpha.snapTo(0F) viewerContainerAlpha.snapTo(1F) } else { transformContentAlpha.snapTo(1F) viewerContainerAlpha.snapTo(0F) } } /** * 将viewer容器的位置大小复制给transformContent */ internal suspend fun copyViewerContainerStateToTransformState() { transformState.apply { val targetScale = scale.value * fitScale graphicScaleX.snapTo(targetScale) graphicScaleY.snapTo(targetScale) val centerOffsetY = (containerSize.height - realSize.height).div(2) val centerOffsetX = (containerSize.width - realSize.width).div(2) offsetY.snapTo(centerOffsetY + this@ViewerContainerState.offsetY.value) offsetX.snapTo(centerOffsetX + this@ViewerContainerState.offsetX.value) } } /** * 将viewer的位置大小等信息复制给transformContent * @param itemState TransformItemState */ internal suspend fun copyViewerPosToContent(itemState: TransformItemState) { transformState.apply { // 更新itemState,确保itemState一致 this@apply.itemState = itemState // 确保viewer的容器大小与transform的容器大小一致 containerSize = imageViewerState.containerSize val scale = imageViewerState.scale val offsetX = imageViewerState.offsetX val offsetY = imageViewerState.offsetY // 计算transform的实际大小 val rw = fitSize.width * scale.value val rh = fitSize.height * scale.value // 计算目标平移量 val goOffsetX = (containerSize.width - rw).div(2) + offsetX.value val goOffsetY = (containerSize.height - rh).div(2) + offsetY.value // 计算缩放率 val fixScale = fitScale * scale.value // 更新值 graphicScaleX.snapTo(fixScale) graphicScaleY.snapTo(fixScale) displayWidth.snapTo(displayRatioSize.width) displayHeight.snapTo(displayRatioSize.height) this@apply.offsetX.snapTo(goOffsetX) this@apply.offsetY.snapTo(goOffsetY) } } // 容器大小 var containerSize: IntSize by mutableStateOf(IntSize.Zero) // 容器的偏移量X var offsetX = Animatable(0F) // 容器的偏移量Y var offsetY = Animatable(0F) // 容器缩放 var scale = Animatable(1F) /** * 重置回原来的状态 * @param animationSpec AnimationSpec */ suspend fun reset(animationSpec: AnimationSpec = defaultAnimationSpec) { scope.apply { listOf( async { offsetX.animateTo(0F, animationSpec) }, async { offsetY.animateTo(0F, animationSpec) }, async { scale.animateTo(1F, animationSpec) }, ).awaitAll() } } /** * 立刻重置 */ suspend fun resetImmediately() { offsetX.snapTo(0F) offsetY.snapTo(0F) scale.snapTo(1F) } companion object { val Saver: Saver = mapSaver( save = { mapOf( it::offsetX.name to it.offsetX.value, it::offsetY.name to it.offsetY.value, it::scale.name to it.scale.value, ) }, restore = { val viewerContainerState = ViewerContainerState() viewerContainerState.offsetX = Animatable(it[viewerContainerState::offsetX.name] as Float) viewerContainerState.offsetY = Animatable(it[viewerContainerState::offsetY.name] as Float) viewerContainerState.scale = Animatable(it[viewerContainerState::scale.name] as Float) viewerContainerState } ) } } /** * 记录Viewer容器的状态 * @return ViewerContainerState */ @Composable internal fun rememberViewerContainerState( // 协程作用域 scope: CoroutineScope = rememberCoroutineScope(), // viewer状态 viewerState: ImageViewerState = rememberViewerState(), // 转换content的state transformContentState: TransformContentState = rememberTransformContentState(), // 动画窗格 animationSpec: AnimationSpec = DEFAULT_SOFT_ANIMATION_SPEC, ): ViewerContainerState { val viewerContainerState = rememberSaveable(saver = ViewerContainerState.Saver) { ViewerContainerState() } viewerContainerState.scope = scope viewerContainerState.imageViewerState = viewerState viewerContainerState.transformState = transformContentState viewerContainerState.defaultAnimationSpec = animationSpec return viewerContainerState } /** * Viewer容器 */ @Composable internal fun ImageViewerContainer( // 修改对象 modifier: Modifier = Modifier, // 容器状态 containerState: ViewerContainerState, // 未加载成功时的占位 placeholder: PreviewerPlaceholder = PreviewerPlaceholder(), // viewer主体 viewer: @Composable () -> Unit, ) { containerState.apply { Box( modifier = modifier .fillMaxSize() .onGloballyPositioned { containerSize = it.size } .graphicsLayer { scaleX = scale.value scaleY = scale.value translationX = offsetX.value translationY = offsetY.value } ) { // 支持转换效果的图层 Box( modifier = Modifier .fillMaxSize() .alpha(transformContentAlpha.value) ) { TransformContentView(transformState) } // viewer图层 Box( modifier = Modifier .fillMaxSize() .alpha(viewerContainerAlpha.value) ) { viewer() } // 判断viewer是否加载成功 val viewerMounted by imageViewerState.mountedFlow.collectAsState( initial = false ) if (allowLoading) AnimatedVisibility( visible = !viewerMounted, enter = placeholder.enterTransition, exit = placeholder.exitTransition, ) { placeholder.content() } } } } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/previewer/PreviewerPagerState.kt ================================================ package com.origeek.imageViewer.previewer import androidx.annotation.FloatRange import androidx.annotation.IntRange import com.origeek.imageViewer.gallery.ImageGalleryState /** * @program: ImageViewer * * @description: * * @author: JVZIYAOYAO * * @create: 2022-10-17 14:41 **/ open class PreviewerPagerState( val galleryState: ImageGalleryState, ) { /** * 当前页码 */ val currentPage: Int get() = galleryState.currentPage /** * 目标页码 */ val targetPage: Int get() = galleryState.targetPage /** * 滚动到指定页面 * @param page Int * @param pageOffset Float */ suspend fun scrollToPage( @IntRange(from = 0) page: Int, @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0F, ) = galleryState.scrollToPage(page, pageOffset) /** * 带动画滚动到指定页面 * @param page Int * @param pageOffset Float */ suspend fun animateScrollToPage( @IntRange(from = 0) page: Int, @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0F, ) = galleryState.animateScrollToPage(page, pageOffset) } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/previewer/PreviewerTransformState.kt ================================================ package com.origeek.imageViewer.previewer import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.origeek.imageViewer.gallery.ImageGalleryState import com.origeek.imageViewer.util.Ticket import com.origeek.imageViewer.viewer.ImageViewerState import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine /** * @program: ImageViewer * * @description: * * @author: JVZIYAOYAO * * @create: 2022-10-17 14:41 **/ open class PreviewerTransformState( // 协程作用域 var scope: CoroutineScope = MainScope(), // 默认动画窗格 var defaultAnimationSpec: AnimationSpec = DEFAULT_SOFT_ANIMATION_SPEC, // 预览状态 galleryState: ImageGalleryState, ) : PreviewerPagerState( galleryState = galleryState, ) { /** * +-------------------+ * PRIVATE * +-------------------+ */ // 锁对象 private var mutex = Mutex() // 打开回调,最外层animateVisible修改时调用 private var openCallback: (() -> Unit)? = null // 关闭回调,最外层animateVisible修改时调用 private var closeCallback: (() -> Unit)? = null // 是否显示viewer容器的标识 private val viewerContainerVisible: Boolean get() = viewerContainerState?.viewerContainerAlpha?.value == 1F /** * 更新当前的标记状态 * @param animating Boolean * @param visible Boolean * @param visibleTarget Boolean? */ private suspend fun updateState(animating: Boolean, visible: Boolean, visibleTarget: Boolean?) { mutex.withLock { this.animating = animating this.visible = visible this.visibleTarget = visibleTarget } } /** * +-------------------+ * INTERNAL * +-------------------+ */ // 等待界面刷新的ticket internal val ticket = Ticket() // 最外侧animateVisibleState internal var animateContainerVisibleState by mutableStateOf(MutableTransitionState(false)) // UI透明度 internal var uiAlpha = Animatable(0F) // viewer透明度 internal var viewerAlpha = Animatable(1F) // 从外部传入viewer容器 internal var viewerContainerState by mutableStateOf(null) // 从外部提供transformContentState internal val transformState: TransformContentState? get() = viewerContainerState?.transformState // 进入转换动画 internal var enterTransition: EnterTransition? = null // 离开的转换动画 internal var exitTransition: ExitTransition? = null // 判断是否允许transform结束 internal val canTransformOut: Boolean get() = (viewerContainerState?.openTransformJob != null) || (imageViewerState?.mountedFlow?.value == true) // 标记打开动作,执行开始 internal suspend fun stateOpenStart() = updateState(animating = true, visible = false, visibleTarget = true) // 标记打开动作,执行结束 internal suspend fun stateOpenEnd() = updateState(animating = false, visible = true, visibleTarget = null) // 标记关闭动作,执行开始 internal suspend fun stateCloseStart() = updateState(animating = true, visible = true, visibleTarget = false) // 标记关闭动作,执行结束 internal suspend fun stateCloseEnd() = updateState(animating = false, visible = false, visibleTarget = null) /** * 转换图层转viewer图层,true显示viewer,false显示转换图层 * @param isViewer Boolean */ internal suspend fun transformSnapToViewer(isViewer: Boolean) { if (isViewer && visibleTarget == false) return viewerContainerState?.transformSnapToViewer(isViewer) } /** * animateVisable执行完成后调用回调方法 */ internal fun onAnimateContainerStateChanged() { if (animateContainerVisibleState.currentState) { openCallback?.invoke() transformState?.setEnterState() } else { closeCallback?.invoke() } } /** * +-------------------+ * PUBLIC * +-------------------+ */ // 是否正在进行动画 var animating by mutableStateOf(false) internal set // 是否可见 var visible by mutableStateOf(false) internal set // 是否可见的目标值 var visibleTarget by mutableStateOf(null) internal set // 是否允许执行open操作 val canOpen: Boolean get() = !visible && visibleTarget == null && !animating // 是否允许执行close操作 val canClose: Boolean get() = visible && visibleTarget == null && !animating // imageViewer状态对象 val imageViewerState: ImageViewerState? get() = viewerContainerState?.imageViewerState /** * 根据页面获取当前页码所属的key */ var getKey: ((Int) -> Any)? = null // 查找key关联的transformItem fun findTransformItem(key: Any): TransformItemState? { return transformItemStateMap[key] } // 根据index查询key fun findTransformItemByIndex(index: Int): TransformItemState? { val key = getKey?.invoke(index) ?: return null return findTransformItem(key) } // 清除全部transformItems fun clearTransformItems() = transformItemStateMap.clear() /** * 打开previewer * @param index Int * @param itemState TransformItemState? * @param enterTransition EnterTransition? */ suspend fun open( index: Int = 0, itemState: TransformItemState? = null, enterTransition: EnterTransition? = null ) = suspendCoroutine { c -> // 设置当前转换动画 this.enterTransition = enterTransition // 设置转换回调 openCallback = { c.resume(Unit) // 清除转换回调 openCallback = null // 清除转换动画 this.enterTransition = null // 标记结束 scope.launch { stateOpenEnd() } } scope.launch { // 标记开始 stateOpenStart() // 开启UI uiAlpha.snapTo(1F) // container动画立即设置为关闭 animateContainerVisibleState = MutableTransitionState(false) // 开启container animateContainerVisibleState.targetState = true // 跳转到index galleryState.scrollToPage(index) // 等待下一帧之后viewerContainerState才会刷新出来 ticket.awaitNextTicket() // 允许显示loading viewerContainerState?.allowLoading = true // 开启viewer viewerContainerState?.viewerContainerAlpha?.snapTo(1F) // 如果输入itemState,则用itemState做为背景 if (itemState != null) { scope.launch { viewerContainerState?.transformContentAlpha?.snapTo(1F) transformState?.awaitContainerSizeSpecifier() transformState?.enterTransform(itemState, tween(0)) } } // 这里需要等待viewer挂载,显示loading界面 viewerContainerState?.awaitOpenTransform() } } /** * 关闭previewer * @param exitTransition ExitTransition? */ suspend fun close(exitTransition: ExitTransition? = null) = suspendCoroutine { c -> // 设置当前退出动画 this.exitTransition = exitTransition // 设置退出结束的回调方法 closeCallback = { c.resume(Unit) // 将回调设置为空 closeCallback = null // 将退出动画设置为空 this.exitTransition = null // 标记结束 scope.launch { stateCloseEnd() } } scope.launch { // 标记开始 stateCloseStart() // 关闭正在进行的开启操作 viewerContainerState?.cancelOpenTransform() // 这里创建一个全新的state是为了让exitTransition的设置得到响应 animateContainerVisibleState = MutableTransitionState(true) // 开启container关闭动画 animateContainerVisibleState.targetState = false // 等待下一帧 ticket.awaitNextTicket() // transformState标记退出 transformState?.setExitState() } } /** * 打开previewer,带转换效果 * @param index Int * @param itemState TransformItemState * @param animationSpec AnimationSpec? */ suspend fun openTransform( index: Int, itemState: TransformItemState? = findTransformItemByIndex(index), animationSpec: AnimationSpec = defaultAnimationSpec ) { // 如果itemState为空,改用open的方式打开 if (itemState == null) { open(index) return } // 动画开始 stateOpenStart() // 关闭UI uiAlpha.snapTo(0F) // 关闭viewer viewerAlpha.snapTo(0F) // 设置新的container状态立刻设置为true animateContainerVisibleState = MutableTransitionState(true) // 跳转到index页 galleryState.scrollToPage(index) // 等待下一帧 ticket.awaitNextTicket() // 关闭loading viewerContainerState?.allowLoading = false // 关闭viewer。打开transform transformSnapToViewer(false) // 开启viewer viewerAlpha.snapTo(1F) // 这两个一起执行 listOf( scope.async { // 开启动画 transformState?.enterTransform(itemState, animationSpec) // 开启loading viewerContainerState?.allowLoading = true }, scope.async { // UI慢慢显示 uiAlpha.animateTo(1F, animationSpec) } ).awaitAll() // 执行完成后的回调 stateOpenEnd() // 这里需要等待viewer挂载,显示loading界面 viewerContainerState?.awaitOpenTransform() } /** * 关闭previewer,带转换效果 * @param key Any * @param animationSpec AnimationSpec? */ suspend fun closeTransform( animationSpec: AnimationSpec = defaultAnimationSpec, ) { // 标记开始 stateCloseStart() // 判断当前状态是否允许transform结束 // 需要在cancel前获取该值 val canNowTransformOut = canTransformOut // 关闭可能正在进行的open操作 viewerContainerState?.cancelOpenTransform() // 关闭loading的显示 viewerContainerState?.allowLoading = false // 查询item是否存在 val itemState = findTransformItemByIndex(currentPage) // 如果存在,就transform退出,否则就普通退出 if (itemState != null && canNowTransformOut) { // 如果viewer在显示的状态,退出时将viewer的pose复制给content if (viewerContainerVisible) { // 标记transform的开始状态,否则copy无效 transformState?.setEnterState() // 更新transformState transformState?.notifyEnterChanged() // 等待刷新完毕 ticket.awaitNextTicket() // 复制viewer的pos给transform viewerContainerState?.copyViewerPosToContent(itemState) // 切换为transform transformSnapToViewer(false) } // 等待下一帧 ticket.awaitNextTicket() listOf( scope.async { // transform动画退出 transformState?.exitTransform(animationSpec) // 退出结束后隐藏content viewerContainerState?.transformContentAlpha?.snapTo(0F) }, scope.async { // 动画隐藏UI uiAlpha.animateTo(0F, animationSpec) } ).awaitAll() // 等待下一帧 ticket.awaitNextTicket() // 彻底关闭container animateContainerVisibleState = MutableTransitionState(false) } else { // transform标记退出 transformState?.setExitState() // container动画退出 animateContainerVisibleState.targetState = false } // 允许使用loading viewerContainerState?.allowLoading = true // 标记结束 stateCloseEnd() } } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/previewer/PreviewerVerticalDragState.kt ================================================ package com.origeek.imageViewer.previewer import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.MutableTransitionState import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputScope import com.origeek.imageViewer.gallery.ImageGalleryState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlin.math.absoluteValue /** * @program: ImageViewer * * @description: * * @author: JVZIYAOYAO * * @create: 2022-10-17 14:42 **/ // 默认下拉关闭缩放阈值 const val DEFAULT_SCALE_TO_CLOSE_MIN_VALUE = 0.9F enum class VerticalDragType { // 不开启垂直手势 None, // 仅开启下拉手势 Down, // 支持上下拉手势 UpAndDown, ; } /** * 增加垂直方向拖拽的能力 */ open class PreviewerVerticalDragState( // 协程作用域 scope: CoroutineScope = MainScope(), // 默认动画窗格 defaultAnimationSpec: AnimationSpec = DEFAULT_SOFT_ANIMATION_SPEC, // 开启垂直手势的类型 verticalDragType: VerticalDragType = VerticalDragType.None, // 下拉关闭的缩小的阈值 scaleToCloseMinValue: Float = DEFAULT_SCALE_TO_CLOSE_MIN_VALUE, // 预览状态 galleryState: ImageGalleryState, ) : PreviewerTransformState(scope, defaultAnimationSpec, galleryState) { /** * viewer容器缩小关闭 */ private suspend fun viewerContainerShrinkDown() { // 标记动作开始 stateCloseStart() listOf( // 缩小容器 scope.async { viewerContainerState?.scale?.animateTo(0F, animationSpec = defaultAnimationSpec) }, // 关闭UI scope.async { uiAlpha.animateTo(0F, animationSpec = defaultAnimationSpec) } ).awaitAll() // 等待下一帧 ticket.awaitNextTicket() // 关闭动画组件 animateContainerVisibleState = MutableTransitionState(false) // 等待下一帧 ticket.awaitNextTicket() // 重置container viewerContainerState?.reset(defaultAnimationSpec) // 将transform标记为退出 transformState?.setExitState() // 标记动作结束 stateCloseEnd() } /** * 响应下拉关闭 */ private suspend fun dragDownClose() { // 刷新transform的pos transformState?.notifyEnterChanged() // 关闭loading viewerContainerState?.allowLoading = false // 等待下一帧,确保transform的pos刷新成功 ticket.awaitNextTicket() // 将container的pos复制给transform viewerContainerState?.copyViewerContainerStateToTransformState() // container重置 viewerContainerState?.resetImmediately() // 切换到transform transformSnapToViewer(false) // 等待下一帧 ticket.awaitNextTicket() // 执行转换关闭 closeTransform(defaultAnimationSpec) // 解除loading限制 viewerContainerState?.allowLoading = true } /** * 设置下拉手势的方法 * @param pointerInputScope PointerInputScope */ internal suspend fun verticalDrag(pointerInputScope: PointerInputScope) { pointerInputScope.apply { // 记录开始时的位置 var vStartOffset by mutableStateOf(null) // 标记是否为下拉关闭 var vOrientationDown by mutableStateOf(null) // 如果getKay不为空才开始检测手势 if (verticalDragType != VerticalDragType.None) detectVerticalDragGestures( onDragStart = OnDragStart@{ // 如果imageViewerState不存在,无法进行下拉手势 if (imageViewerState == null) return@OnDragStart var transformItemState: TransformItemState? = null // 查询当前transformItem getKey?.apply { findTransformItem(invoke(currentPage))?.apply { transformItemState = this } } // 判断是否允许变换退出,如果允许就标记动作开始 // setExitState后,在下拉过程中,itemState不会从界面上消失 if (canTransformOut) { transformState?.setEnterState() } else { transformState?.setExitState() } // 更新当前transformItem transformState?.itemState = transformItemState // 只有viewer的缩放率为1时才允许下拉手势 if (imageViewerState?.scale?.value == 1F) { vStartOffset = it // 进入下拉手势时禁用viewer的手势 imageViewerState?.allowGestureInput = false } }, onDragEnd = OnDragEnd@{ // 如果开始位置为空,就退出 if (vStartOffset == null) return@OnDragEnd // 如果containerState为空,就退出 if (viewerContainerState == null) return@OnDragEnd // 重置开始位置和方向 vStartOffset = null vOrientationDown = null // 解除viewer的手势输入限制 imageViewerState?.allowGestureInput = true // 缩放小于阈值,执行关闭动画,大于就恢复原样 if (viewerContainerState!!.scale.value < scaleToCloseMinValue) { scope.launch { if (getKey != null && canTransformOut) { val key = getKey!!.invoke(currentPage) val transformItem = findTransformItem(key) // 如果item在画面内,就执行变换关闭,否则缩小关闭 if (transformItem != null) { dragDownClose() } else { viewerContainerShrinkDown() } } else { viewerContainerShrinkDown() } // 结束动画后需要把关闭的UI打开 uiAlpha.snapTo(1F) } } else { scope.launch { uiAlpha.animateTo(1F, defaultAnimationSpec) } scope.launch { viewerContainerState?.reset(defaultAnimationSpec) } } }, onVerticalDrag = OnVerticalDrag@{ change, dragAmount -> if (imageViewerState == null) return@OnVerticalDrag if (viewerContainerState == null) return@OnVerticalDrag if (vStartOffset == null) return@OnVerticalDrag if (vOrientationDown == null) vOrientationDown = dragAmount > 0 if (vOrientationDown == true || verticalDragType == VerticalDragType.UpAndDown) { val offsetY = change.position.y - vStartOffset!!.y val offsetX = change.position.x - vStartOffset!!.x val containerHeight = viewerContainerState!!.containerSize.height val scale = (containerHeight - offsetY.absoluteValue).div( containerHeight ) scope.launch { uiAlpha.snapTo(scale) viewerContainerState?.offsetX?.snapTo(offsetX) viewerContainerState?.offsetY?.snapTo(offsetY) viewerContainerState?.scale?.snapTo(scale) } } else { // 如果不是向上,就返还输入权,以免页面卡顿 imageViewerState?.allowGestureInput = true } } ) } } /** * 开启垂直手势的类型 */ var verticalDragType by mutableStateOf(verticalDragType) /** * 下拉关闭的缩放的阈值,当scale小于这个值,就关闭,否则还原 */ var scaleToCloseMinValue by mutableStateOf(scaleToCloseMinValue) } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/util/Ticket.kt ================================================ package com.origeek.imageViewer.util import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import java.util.UUID import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine /** * @program: ImageViewer * * @description: * * @author: JVZIYAOYAO * * @create: 2023-05-24 12:15 **/ class Ticket { private var ticket by mutableStateOf("") private val ticketMap = mutableMapOf>() suspend fun awaitNextTicket() = suspendCoroutine { c -> ticket = UUID.randomUUID().toString() ticketMap[ticket] = c } private fun clearTicket() { ticketMap.forEach { it.value.resume(Unit) ticketMap.remove(it.key) } } @Composable fun Next() { LaunchedEffect(ticket) { clearTicket() } } } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/viewer/ImageComposeCanvas.kt ================================================ package com.origeek.imageViewer.viewer import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.BitmapRegionDecoder import android.graphics.Matrix import android.graphics.Rect import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import com.origeek.imageViewer.previewer.DEFAULT_CROSS_FADE_ANIMATE_SPEC import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode import java.util.concurrent.LinkedBlockingDeque import kotlin.math.absoluteValue import kotlin.math.ceil data class RenderBlock( var inBound: Boolean = false, var inSampleSize: Int = 1, var renderOffset: IntOffset = IntOffset.Zero, var renderSize: IntSize = IntSize.Zero, var sliceRect: Rect = Rect(0, 0, 0, 0), private var bitmap: Bitmap? = null, ) { fun release() { bitmap?.recycle() bitmap = null } fun getBitmap(): Bitmap? { return bitmap } fun setBitmap(bitmap: Bitmap) { this.bitmap = bitmap } } val ROTATION_0 = 0 val ROTATION_90 = 90 val ROTATION_180 = 180 val ROTATION_270 = 270 class RotationIllegalException(msg: String = "Illegal rotation angle.") : RuntimeException(msg) class ImageDecoder( private val decoder: BitmapRegionDecoder, private val rotation: Int = ROTATION_0, private val onRelease: () -> Unit = {}, ) : CoroutineScope by MainScope() { // 解码的宽度 var decoderWidth by mutableStateOf(0) private set // 解码的高度 var decoderHeight by mutableStateOf(0) private set // 解码区块大小 var blockSize by mutableStateOf(0) private set // 渲染列表 var renderList: Array> = emptyArray() private set // 解码渲染队列 val renderQueue = LinkedBlockingDeque() // 横向方块数 private var countW = 0 // 纵向方块数 private var countH = 0 // 最长边的最大方块数 private var maxBlockCount = 0 init { // 初始化最大方块数 setMaxBlockCount(1) } // 构造一个渲染方块队列 private fun getRenderBlockList(): Array> { var endX: Int var endY: Int var sliceStartX: Int var sliceStartY: Int var sliceEndX: Int var sliceEndY: Int return Array(countH) { column -> sliceStartY = (column * blockSize) endY = (column + 1) * blockSize sliceEndY = if (endY > decoderHeight) decoderHeight else endY Array(countW) { row -> sliceStartX = (row * blockSize) endX = (row + 1) * blockSize sliceEndX = if (endX > decoderWidth) decoderWidth else endX RenderBlock( sliceRect = Rect( sliceStartX, sliceStartY, sliceEndX, sliceEndY, ) ) } } } // 设置最长边最大方块数 fun setMaxBlockCount(count: Int): Boolean { if (maxBlockCount == count) return false if (decoder.isRecycled) return false when (rotation) { ROTATION_0, ROTATION_180 -> { decoderWidth = decoder.width decoderHeight = decoder.height } ROTATION_90, ROTATION_270 -> { decoderWidth = decoder.height decoderHeight = decoder.width } else -> throw RotationIllegalException() } maxBlockCount = count blockSize = (decoderWidth.coerceAtLeast(decoderHeight)).toFloat().div(count).toInt() countW = ceil(decoderWidth.toFloat().div(blockSize)).toInt() countH = ceil(decoderHeight.toFloat().div(blockSize)).toInt() renderList = getRenderBlockList() return true } // 遍历每一个渲染方块 fun forEachBlock(action: (block: RenderBlock, column: Int, row: Int) -> Unit) { for ((column, rows) in renderList.withIndex()) { for ((row, block) in rows.withIndex()) { action(block, column, row) } } } // 清除全部bitmap的引用 fun clearAllBitmap() { forEachBlock { block, _, _ -> block.release() } } // 释放资源 fun release() { synchronized(decoder) { if (!decoder.isRecycled) { // 清除渲染队列 renderQueue.clear() // 回收资源 decoder.recycle() // 发送一个信号停止堵塞的循环 renderQueue.putFirst(RenderBlock()) } onRelease() } } fun getRotateBitmap(bitmap: Bitmap, degree: Float): Bitmap { val matrix = Matrix() matrix.postRotate(degree) return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false) } /** * 解码渲染区域 */ fun decodeRegion(inSampleSize: Int, rect: Rect): Bitmap? { synchronized(decoder) { return try { val ops = BitmapFactory.Options() ops.inSampleSize = inSampleSize if (decoder.isRecycled) return null return if (rotation == ROTATION_0) { decoder.decodeRegion(rect, ops) } else { val newRect = when (rotation) { ROTATION_90 -> { val nextX1 = rect.top val nextX2 = rect.bottom val nextY1 = decoderWidth - rect.right val nextY2 = decoderWidth - rect.left Rect(nextX1, nextY1, nextX2, nextY2) } ROTATION_180 -> { val nextX1 = decoderWidth - rect.right val nextX2 = decoderWidth - rect.left val nextY1 = decoderHeight - rect.bottom val nextY2 = decoderHeight - rect.top Rect(nextX1, nextY1, nextX2, nextY2) } ROTATION_270 -> { val nextX1 = decoderHeight - rect.bottom val nextX2 = decoderHeight - rect.top val nextY1 = rect.left val nextY2 = rect.right Rect(nextX1, nextY1, nextX2, nextY2) } else -> throw RotationIllegalException() } val srcBitmap = decoder.decodeRegion(newRect, ops) getRotateBitmap(bitmap = srcBitmap, rotation.toFloat()) } } catch (e: Exception) { e.printStackTrace() null } } } // 开启堵塞队列的循环 fun startRenderQueue(onUpdate: () -> Unit) { launch(Dispatchers.IO) { try { while (!decoder.isRecycled) { val block = renderQueue.take() if (decoder.isRecycled) break val bitmap = decodeRegion(block.inSampleSize, block.sliceRect) if (bitmap != null) block.setBitmap(bitmap) onUpdate() } } catch (e: InterruptedException) { e.printStackTrace() } } } } @Composable fun ImageComposeCanvas( modifier: Modifier = Modifier, imageDecoder: ImageDecoder, scale: Float = DEFAULT_SCALE, offsetX: Float = DEFAULT_OFFSET_X, offsetY: Float = DEFAULT_OFFSET_Y, rotation: Float = DEFAULT_ROTATION, gesture: RawGesture = RawGesture(), onMounted: () -> Unit = {}, onSizeChange: suspend (SizeChangeContent) -> Unit = {}, crossfadeAnimationSpec: AnimationSpec = DEFAULT_CROSS_FADE_ANIMATE_SPEC, boundClip: Boolean = true, debugMode: Boolean = false, ) { val scope = rememberCoroutineScope() // 容器大小 var bSize by remember { mutableStateOf(IntSize.Zero) } // 容器长宽比 val bRatio by remember { derivedStateOf { bSize.width.toFloat() / bSize.height.toFloat() } } // 原图长宽比 val oRatio by remember { derivedStateOf { imageDecoder.decoderWidth.toFloat() / imageDecoder.decoderHeight.toFloat() } } // 是否宽度与容器大小一致 var widthFixed by remember { mutableStateOf(false) } // 长宽是否均超出容器长宽 val superSize by remember { derivedStateOf { imageDecoder.decoderHeight > bSize.height && imageDecoder.decoderWidth > bSize.width } } // 显示的默认大小 val uSize by remember { derivedStateOf { if (oRatio > bRatio) { // 宽度一致 val uW = bSize.width val uH = uW / oRatio widthFixed = true IntSize(uW, uH.toInt()) } else { // 高度一致 val uH = bSize.height val uW = uH * oRatio widthFixed = false IntSize(uW.toInt(), uH) } } } // 显示的实际大小 val rSize by remember(key1 = scale) { derivedStateOf { IntSize( (uSize.width * scale).toInt(), (uSize.height * scale).toInt() ) } } // 同时监听容器和实际图片大小的变化 LaunchedEffect(key1 = bSize, key2 = rSize) { // 获取最大缩放率 val maxScale = when { superSize -> { imageDecoder.decoderWidth.toFloat() / uSize.width.toFloat() } widthFixed -> { bSize.height.toFloat() / uSize.height.toFloat() } else -> { bSize.width.toFloat() / uSize.width.toFloat() } } // 回调 onSizeChange( SizeChangeContent( defaultSize = uSize, containerSize = bSize, maxScale = maxScale ) ) } // 判断是否需要高画质渲染 val needRenderHeightTexture by remember(key1 = bSize) { derivedStateOf { // 目前策略:原图的面积大于容器面积,就要渲染高画质 BigDecimal(imageDecoder.decoderWidth) .multiply(BigDecimal(imageDecoder.decoderHeight)) > BigDecimal(bSize.height) .multiply(BigDecimal(bSize.width)) } } // 标识当前是否开启高画质渲染,如果需要高画质渲染,并且缩放大于1 val renderHeightTexture by remember(key1 = scale) { derivedStateOf { needRenderHeightTexture && scale > 1 } } // 当前采样率 var inSampleSize by remember { mutableStateOf(1) } // 最小图的采样率 var zeroInSampleSize by remember { mutableStateOf(8) } // 底图的采样率 var backGroundInSample by remember { mutableStateOf(0) } // 底图bitmap var bitmap by remember { mutableStateOf(null) } // 监听渲染实际大小,动态修改图片的采样率 LaunchedEffect(key1 = rSize) { if (scale < 1F) return@LaunchedEffect inSampleSize = calculateInSampleSize( srcWidth = imageDecoder.decoderWidth, reqWidth = rSize.width ) if (scale == 1F) { zeroInSampleSize = inSampleSize } } // 根据采样率变化,实时更新底图 LaunchedEffect(key1 = zeroInSampleSize, key2 = inSampleSize, key3 = needRenderHeightTexture) { scope.launch(Dispatchers.IO) { // 如果不需要渲染高画质,就不需要分块渲染,直接使用当前采样率,用底图来展示 val iss = if (needRenderHeightTexture) zeroInSampleSize else inSampleSize if (iss == backGroundInSample) return@launch backGroundInSample = iss bitmap = imageDecoder.decodeRegion( iss, Rect( 0, 0, imageDecoder.decoderWidth, imageDecoder.decoderHeight ) ) } } DisposableEffect(Unit) { onDispose { bitmap?.recycle() bitmap = null } } // 底图偏移量X,要确保图片在容器中居中对齐 val deltaX by remember(key1 = offsetX, key2 = bSize, key3 = rSize) { derivedStateOf { offsetX + (bSize.width - rSize.width).toFloat().div(2) } } // 底图偏移量Y,要确保图片在容器中居中对齐 val deltaY by remember(key1 = offsetY, key2 = bSize, key3 = rSize) { derivedStateOf { offsetY + (bSize.height - rSize.height).toFloat().div(2) } } // 计算显示区域内矩形的宽度 val rectW by remember(key1 = offsetX) { derivedStateOf { calcLeftSize( bSize = bSize.width.toFloat(), rSize = rSize.width.toFloat(), offset = offsetX, ) } } // 计算显示区域内矩形的高度 val rectH by remember(key1 = offsetY, key2 = rSize) { derivedStateOf { calcLeftSize( bSize = bSize.height.toFloat(), rSize = rSize.height.toFloat(), offset = offsetY, ) } } // 渲染可见区域的开始坐标X val stX by remember(key1 = offsetX) { derivedStateOf { // 计算显示区域矩形的偏移坐标 val rectDeltaX = getRectDelta( deltaX, rSize.width.toFloat(), bSize.width.toFloat(), offsetX ) // 偏移坐标减偏移量求出矩形在图片上的相对坐标 rectDeltaX - deltaX } } // 渲染可见区域的开始坐标Y val stY by remember(key1 = offsetY) { derivedStateOf { // 计算显示区域矩形的偏移坐标 val rectDeltaY = getRectDelta( deltaY, rSize.height.toFloat(), bSize.height.toFloat(), offsetY ) // 偏移坐标减偏移量求出矩形在图片上的相对坐标 rectDeltaY - deltaY } } // 开始坐标加上宽度等于结束坐标 val edX by remember(key1 = offsetX) { derivedStateOf { stX + rectW } } // 开始坐标加上高度等于结束坐标 val edY by remember(key1 = offsetY) { derivedStateOf { stY + rectH } } // 更新时间戳,用于通知canvas更新方块 var renderUpdateTimeStamp by remember { mutableStateOf(0L) } // 开启解码队列的循环 LaunchedEffect(key1 = Unit) { imageDecoder.startRenderQueue { // 解码器解码一个,就更新一次时间戳 renderUpdateTimeStamp = System.currentTimeMillis() } } // 切换到不需要高画质渲染时,需要清除解码队列,清除全部的bitmap LaunchedEffect(key1 = renderHeightTexture) { if (!renderHeightTexture) { imageDecoder.renderQueue.clear() imageDecoder.clearAllBitmap() } } /** * 更新渲染队列 */ var calcMaxCountPending by remember { mutableStateOf(false) } // 先前的缩放比 var previousScale by remember { mutableStateOf(null) } // 先前的偏移量 var previousOffset by remember { mutableStateOf(null) } // 记录最长边的最大方块数 var blockDividerCount by remember { mutableStateOf(1) } // 用来标识这个参数是否有改变 var preBlockDividerCount by remember { mutableStateOf(blockDividerCount) } // 更新渲染方块的信息 fun updateRenderList() { // 如果此时正在重新计算渲染方块的数目,就退出 if (calcMaxCountPending) return // 更新的时候如果缩放和偏移量没有变化,方块数量也没变,就没有必要计算了 if ( previousOffset?.x == offsetX && previousOffset?.y == offsetY && previousScale == scale && preBlockDividerCount == blockDividerCount ) return previousScale = scale previousOffset = Offset(offsetX, offsetY) // 计算当前渲染方块大小 val renderBlockSize = imageDecoder.blockSize * (rSize.width.toFloat().div(imageDecoder.decoderWidth)) var tlx: Int var tly: Int var startX: Float var startY: Float var endX: Float var endY: Float var eh: Int var ew: Int var needUpdate: Boolean var previousInBound: Boolean var previousInSampleSize: Int var lastX: Int? var lastY: Int? = null var lastXDelta: Int var lastYDelta: Int val insertList = ArrayList() val removeList = ArrayList() for ((column, list) in imageDecoder.renderList.withIndex()) { startY = column * renderBlockSize endY = (column + 1) * renderBlockSize tly = (deltaY + startY).toInt() eh = (if (endY > rSize.height) rSize.height - startY else renderBlockSize).toInt() // 由于计算的精度问题,需要确保每一个区块都要严丝合缝 lastY?.let { if (it < tly) { lastYDelta = tly - it tly = it eh += lastYDelta } } lastY = tly + eh lastX = null for ((row, block) in list.withIndex()) { startX = row * renderBlockSize tlx = (deltaX + startX).toInt() endX = (row + 1) * renderBlockSize ew = (if (endX > rSize.width) rSize.width - startX else renderBlockSize).toInt() previousInSampleSize = block.inSampleSize previousInBound = block.inBound // 记录当前区块的采用率 block.inSampleSize = inSampleSize // 判断区块是否在可视范围内 block.inBound = checkRectInBound( startX, startY, endX, endY, stX, stY, edX, edY ) // 由于计算的精度问题,需要确保每一个区块都要严丝合缝 lastX?.let { if (it < tlx) { lastXDelta = tlx - it tlx = it ew += lastXDelta } } lastX = tlx + ew // 记录区块的实际偏移量 block.renderOffset = IntOffset(tlx, tly) // 记录区块的实际大小 block.renderSize = IntSize( width = ew, height = eh, ) // 如果参数跟之前的一样,就没有必要更新bitmap needUpdate = previousInBound != block.inBound || previousInSampleSize != block.inSampleSize if (!needUpdate) continue if (!renderHeightTexture) continue // 解码队列操作时是有锁的,会对性能造成影响 if (block.inBound) { if (!imageDecoder.renderQueue.contains(block)) { insertList.add(block) } } else { removeList.add(block) block.release() } } } scope.launch(Dispatchers.IO) { synchronized(imageDecoder.renderQueue) { insertList.forEach { imageDecoder.renderQueue.putFirst(it) } removeList.forEach { imageDecoder.renderQueue.remove(it) } } } } LaunchedEffect(key1 = rSize, key2 = rectW, key3 = rectH) { // 可视区域面积 val rectArea = BigDecimal(rectW.toDouble()).multiply(BigDecimal(rectH.toDouble())) // 实际大小面积 val realArea = BigDecimal(rSize.width).multiply(BigDecimal(rSize.height)) // 被除数不能为0 if (realArea.toFloat() == 0F) return@LaunchedEffect // 计算实际面积的可视率 val renderAreaPercentage = rectArea.divide(realArea, 2, RoundingMode.HALF_EVEN).toFloat() // 根据不同可视率,匹配合适的方块数,最大只能到8 val goBlockDividerCount = when { renderAreaPercentage > 0.6F -> 1 renderAreaPercentage > 0.025F -> 4 else -> 8 } // 如果没变,就不要修改 if (goBlockDividerCount == blockDividerCount) return@LaunchedEffect preBlockDividerCount = blockDividerCount blockDividerCount = goBlockDividerCount scope.launch(Dispatchers.IO) { // 清空解码队列 imageDecoder.renderQueue.clear() // 进入修改区间 calcMaxCountPending = true imageDecoder.setMaxBlockCount(blockDividerCount) calcMaxCountPending = false // 离开修改区间 // 更新一下界面 updateRenderList() } } // 旋转中心 val rotationCenter by remember(key1 = offsetX, key2 = offsetY, key3 = scale) { derivedStateOf { val cx = deltaX + rSize.width.div(2) val cy = deltaY + rSize.height.div(2) Offset(cx, cy) } } /** * canvas加载成功后避免闪一下 */ val canvasAlpha = remember { Animatable(0F) } LaunchedEffect(key1 = bitmap) { if (bitmap != null && bitmap!!.width > 1 && bitmap!!.height > 1) { if (canvasAlpha.value == 0F) { scope.launch { canvasAlpha.animateTo( targetValue = 1F, animationSpec = crossfadeAnimationSpec ) onMounted() } } } } Canvas( modifier = modifier .alpha(canvasAlpha.value) .fillMaxSize() .graphicsLayer { // 图片位移时会超出容器大小,需要在这个地方指定是否裁切 clip = boundClip } .onSizeChanged { bSize = it } .pointerInput(Unit) { detectTapGestures(onLongPress = gesture.onLongPress) } .pointerInput(Unit) { detectTransformGestures( onTap = gesture.onTap, onDoubleTap = gesture.onDoubleTap, gestureStart = gesture.gestureStart, gestureEnd = gesture.gestureEnd, onGesture = gesture.onGesture, ) }, ) { withTransform({ rotate(degrees = rotation, pivot = rotationCenter) }) { if (bitmap != null) { drawImage( image = bitmap!!.asImageBitmap(), dstSize = IntSize(rSize.width, rSize.height), dstOffset = IntOffset(deltaX.toInt(), deltaY.toInt()), ) } // 更新渲染队列 if (renderUpdateTimeStamp >= 0) updateRenderList() if (renderHeightTexture && !calcMaxCountPending) { imageDecoder.forEachBlock { block, _, _ -> block.getBitmap()?.let { drawImage( image = it.asImageBitmap(), dstSize = block.renderSize, dstOffset = block.renderOffset ) } } } // 这里会把可视区域的矩形画出来 if (debugMode) { drawRect( color = Color.Blue.copy(0.1F), topLeft = Offset(deltaX + stX, deltaY + stY), size = Size(rectW, rectH) ) } } } } fun checkRectInBound( stX1: Float, stY1: Float, edX1: Float, edY1: Float, stX2: Float, stY2: Float, edX2: Float, edY2: Float, ): Boolean { if (edY1 < stY2) return false if (stY1 > edY2) return false if (edX1 < stX2) return false if (stX1 > edX2) return false return true } fun getRectDelta(delta: Float, rSize: Float, bSize: Float, offset: Float): Float { return delta + if (delta < 0) { val direction = if (rSize > bSize) -1 else 1 (offset + (direction) * (bSize - rSize) .div(2).absoluteValue).absoluteValue } else 0F } fun calcLeftSize(bSize: Float, rSize: Float, offset: Float): Float { return if (offset.absoluteValue > (bSize - rSize).div(2).absoluteValue) { rSize - (offset.absoluteValue - (bSize - rSize).div(2)) } else { rSize.coerceAtMost(bSize) } } fun calculateInSampleSize( srcWidth: Int, reqWidth: Int, ): Int { var inSampleSize = 1 while (true) { val iss = inSampleSize * 2 if (srcWidth.toFloat().div(iss) < reqWidth) break inSampleSize = iss } return inSampleSize } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/viewer/ImageComposeOrigin.kt ================================================ package com.origeek.imageViewer.viewer import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.foundation.Image import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize import com.origeek.imageViewer.previewer.DEFAULT_CROSS_FADE_ANIMATE_SPEC import kotlinx.coroutines.launch class RawGesture( val onTap: (Offset) -> Unit = {}, val onDoubleTap: (Offset) -> Unit = {}, val onLongPress: (Offset) -> Unit = {}, val gestureStart: () -> Unit = {}, val gestureEnd: (transformOnly: Boolean) -> Unit = {}, val onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float, event: PointerEvent) -> Boolean = { _, _, _, _, _ -> true }, ) data class SizeChangeContent( val defaultSize: IntSize, val containerSize: IntSize, val maxScale: Float, ) @Composable fun ImageComposeOrigin( modifier: Modifier = Modifier, model: Any, scale: Float = DEFAULT_SCALE, offsetX: Float = DEFAULT_OFFSET_X, offsetY: Float = DEFAULT_OFFSET_Y, rotation: Float = DEFAULT_ROTATION, gesture: RawGesture = RawGesture(), onMounted: () -> Unit = {}, onSizeChange: suspend (SizeChangeContent) -> Unit = {}, crossfadeAnimationSpec: AnimationSpec = DEFAULT_CROSS_FADE_ANIMATE_SPEC, boundClip: Boolean = true, ) { val scope = rememberCoroutineScope() // 容器大小 var bSize by remember { mutableStateOf(IntSize(0, 0)) } // 容器比例 val bRatio by remember { derivedStateOf { bSize.width.toFloat() / bSize.height.toFloat() } } // 图片原始大小 var oSize by remember { mutableStateOf(IntSize(0, 0)) } // 图片原始比例 val oRatio by remember { derivedStateOf { oSize.width.toFloat() / oSize.height.toFloat() } } // 是否宽度与容器大小一致 var widthFixed by remember { mutableStateOf(false) } // 长宽是否均超出容器长宽 val superSize by remember { derivedStateOf { oSize.height > bSize.height && oSize.width > bSize.width } } // 显示大小 val uSize by remember { derivedStateOf { if (oRatio > bRatio) { // 宽度一致 val uW = bSize.width val uH = uW / oRatio widthFixed = true IntSize(uW, uH.toInt()) } else { // 高度一致 val uH = bSize.height val uW = uH * oRatio widthFixed = false IntSize(uW.toInt(), uH) } } } // 图片显示的真实大小 val rSize by remember { derivedStateOf { IntSize( (uSize.width * scale).toInt(), (uSize.height * scale).toInt() ) } } LaunchedEffect(key1 = oSize, key2 = bSize, key3 = rSize) { val maxScale = when { superSize -> { oSize.width.toFloat() / uSize.width.toFloat() } widthFixed -> { bSize.height.toFloat() / uSize.height.toFloat() } else -> { bSize.width.toFloat() / uSize.width.toFloat() } } onSizeChange( SizeChangeContent( defaultSize = uSize, containerSize = bSize, maxScale = maxScale ) ) } // 图片是否加载成功 var imageSpecified by remember { mutableStateOf(false) } // 承载容器的透明度,主要用来控制图片加载成功后的渐变效果 val viewerAlpha = remember { Animatable(0F) } /** * mounted回调 */ fun goMounted() { scope.launch { viewerAlpha.animateTo(1F, crossfadeAnimationSpec) onMounted() } } when (model) { is Painter -> { var isMounted by remember { mutableStateOf(false) } imageSpecified = model.intrinsicSize.isSpecified LaunchedEffect(key1 = model.intrinsicSize, block = { if (imageSpecified) { oSize = IntSize( model.intrinsicSize.width.toInt(), model.intrinsicSize.height.toInt() ) if (!isMounted) { isMounted = true goMounted() } } }) } is ImageVector -> { imageSpecified = true LocalDensity.current.run { oSize = IntSize( model.defaultWidth.toPx().toInt(), model.defaultHeight.toPx().toInt(), ) goMounted() } } is ImageBitmap -> { imageSpecified = true oSize = IntSize( model.width, model.height ) goMounted() } is ComposeModel -> { imageSpecified = true LaunchedEffect(key1 = model.intrinsicSize, block = { oSize = if (model.intrinsicSize == IntSize.Zero) { bSize } else { model.intrinsicSize } }) goMounted() } else -> throw Exception("This model type is not supported!") } Box( modifier = modifier .fillMaxSize() .graphicsLayer { // 图片位移时会超出容器大小,需要在这个地方指定是否裁切 clip = boundClip alpha = viewerAlpha.value } .onSizeChanged { bSize = it } .pointerInput(Unit) { detectTapGestures(onLongPress = gesture.onLongPress) } .pointerInput(key1 = imageSpecified) { if (imageSpecified) detectTransformGestures( onTap = gesture.onTap, onDoubleTap = gesture.onDoubleTap, gestureStart = gesture.gestureStart, gestureEnd = gesture.gestureEnd, onGesture = gesture.onGesture, ) }, contentAlignment = Alignment.Center, ) { val imageModifier = Modifier .graphicsLayer { if (imageSpecified) { scaleX = scale scaleY = scale translationX = offsetX translationY = offsetY rotationZ = rotation } } .size( LocalDensity.current.run { uSize.width.toDp() }, LocalDensity.current.run { uSize.height.toDp() } ) when (model) { is Painter -> { Image( painter = model, contentDescription = null, contentScale = ContentScale.Crop, modifier = imageModifier, ) } is ImageVector -> { Image( imageVector = model, contentDescription = null, contentScale = ContentScale.Crop, modifier = imageModifier, ) } is ImageBitmap -> { Image( bitmap = model, contentDescription = null, contentScale = ContentScale.Crop, modifier = imageModifier, ) } is ComposeModel -> { Box(modifier = imageModifier, contentAlignment = Alignment.Center) { model.PoseContent() } } } } } ================================================ FILE: app/mobile/src/main/kotlin/com/origeek/imageViewer/viewer/ImageViewer.kt ================================================ package com.origeek.imageViewer.viewer import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FloatExponentialDecaySpec import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.generateDecayAnimationSpec import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculateCentroid import androidx.compose.foundation.gestures.calculateCentroidSize import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculateRotation import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.consumeAllChanges import androidx.compose.ui.input.pointer.positionChangeConsumed import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach import androidx.compose.ui.zIndex import com.origeek.imageViewer.previewer.DEFAULT_CROSS_FADE_ANIMATE_SPEC import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlin.math.PI import kotlin.math.abs import kotlin.math.absoluteValue // 默认X轴偏移量 const val DEFAULT_OFFSET_X = 0F // 默认Y轴偏移量 const val DEFAULT_OFFSET_Y = 0F // 默认缩放率 const val DEFAULT_SCALE = 1F // 默认旋转角度 const val DEFAULT_ROTATION = 0F // 图片最小缩放率 const val MIN_SCALE = 0.5F // 图片最大缩放率 const val MAX_SCALE_RATE = 3.2F // 最小手指手势间距 const val MIN_GESTURE_FINGER_DISTANCE = 200 /** * viewer状态对象,用于记录compose组件状态 */ class ImageViewerState( // X轴偏移量 offsetX: Float = DEFAULT_OFFSET_X, // Y轴偏移量 offsetY: Float = DEFAULT_OFFSET_Y, // 缩放率 scale: Float = DEFAULT_SCALE, // 旋转角度 rotation: Float = DEFAULT_ROTATION, // 动画窗格 animationSpec: AnimationSpec? = null, // 淡入淡出效果 crossfadeAnimationSpec: AnimationSpec? = null, ) : CoroutineScope by MainScope() { // 默认动画窗格 var defaultAnimateSpec: AnimationSpec = animationSpec ?: SpringSpec() // viewer挂载成功后显示时的动画窗格 var crossfadeAnimationSpec: AnimationSpec = crossfadeAnimationSpec ?: DEFAULT_CROSS_FADE_ANIMATE_SPEC // x偏移 val offsetX = Animatable(offsetX) // y偏移 val offsetY = Animatable(offsetY) // 放大倍率 val scale = Animatable(scale) // 旋转 val rotation = Animatable(rotation) // 是否允许手势输入 var allowGestureInput by mutableStateOf(true) // 默认显示大小 var defaultSize by mutableStateOf(IntSize(0, 0)) internal set // 容器大小 internal var containerSize by mutableStateOf(IntSize(0, 0)) // 最大缩放 internal var maxScale by mutableStateOf(1F) // 标识是否来自saver,旋转屏幕后会变成true internal var fromSaver = false // 恢复的时间戳 internal var resetTimeStamp by mutableStateOf(0L) // 挂载状态 internal val mountedFlow = MutableStateFlow(false) /** * 判断是否有动画正在运行 * @return Boolean */ internal fun isRunning(): Boolean { return scale.isRunning || offsetX.isRunning || offsetY.isRunning || rotation.isRunning } /** * 立即设置回初始值 */ suspend fun resetImmediately() { rotation.snapTo(DEFAULT_ROTATION) offsetX.snapTo(DEFAULT_OFFSET_X) offsetY.snapTo(DEFAULT_OFFSET_Y) scale.snapTo(DEFAULT_SCALE) } /** * 设置回初始值 */ suspend fun reset(animationSpec: AnimationSpec = defaultAnimateSpec) { coroutineScope { launch { rotation.animateTo(DEFAULT_ROTATION, animationSpec) resetTimeStamp = System.currentTimeMillis() } launch { offsetX.animateTo(DEFAULT_OFFSET_X, animationSpec) resetTimeStamp = System.currentTimeMillis() } launch { offsetY.animateTo(DEFAULT_OFFSET_Y, animationSpec) resetTimeStamp = System.currentTimeMillis() } launch { scale.animateTo(DEFAULT_SCALE, animationSpec) resetTimeStamp = System.currentTimeMillis() } } } /** * 放大到最大 */ suspend fun scaleToMax( offset: Offset, animationSpec: AnimationSpec? = null ) { val currentAnimateSpec = animationSpec ?: defaultAnimateSpec // 计算x和y偏移量和范围,并确保不会在放大过程中超出范围 var bcx = (containerSize.width / 2 - offset.x) * maxScale val boundX = getBound(defaultSize.width.toFloat() * maxScale, containerSize.width.toFloat()) bcx = limitToBound(bcx, boundX) var bcy = (containerSize.height / 2 - offset.y) * maxScale val boundY = getBound(defaultSize.height.toFloat() * maxScale, containerSize.height.toFloat()) bcy = limitToBound(bcy, boundY) // 启动 coroutineScope { launch { scale.animateTo(maxScale, currentAnimateSpec) } launch { offsetX.animateTo(bcx, currentAnimateSpec) } launch { offsetY.animateTo(bcy, currentAnimateSpec) } } } /** * 放大或缩小 */ suspend fun toggleScale( offset: Offset, animationSpec: AnimationSpec = defaultAnimateSpec ) { // 如果不等于1,就调回1 if (scale.value != 1F) { reset(animationSpec) } else { scaleToMax(offset, animationSpec) } } /** * 修正offsetX,offsetY的位置 */ suspend fun fixToBound() { val boundX = getBound(defaultSize.width.toFloat() * scale.value, containerSize.width.toFloat()) val boundY = getBound(defaultSize.height.toFloat() * scale.value, containerSize.height.toFloat()) val limitX = limitToBound(offsetX.value, boundX) val limitY = limitToBound(offsetY.value, boundY) offsetX.snapTo(limitX) offsetY.snapTo(limitY) } companion object { val SAVER: Saver = listSaver(save = { listOf(it.offsetX.value, it.offsetY.value, it.scale.value, it.rotation.value) }, restore = { val state = ImageViewerState( offsetX = it[0], offsetY = it[1], scale = it[2], rotation = it[3], ) state.fromSaver = true state }) } } /** * 记录viewer状态 * @return ImageViewerState 返回一个状态实例 */ @Composable fun rememberViewerState( // X轴偏移量 offsetX: Float = DEFAULT_OFFSET_X, // Y轴偏移量 offsetY: Float = DEFAULT_OFFSET_Y, // 缩放率 scale: Float = DEFAULT_SCALE, // 旋转 rotation: Float = DEFAULT_ROTATION, // 动画窗格 animationSpec: AnimationSpec? = null, // 淡入淡出效果 crossfadeAnimationSpec: AnimationSpec? = null, ): ImageViewerState = rememberSaveable(saver = ImageViewerState.SAVER) { ImageViewerState(offsetX, offsetY, scale, rotation, animationSpec, crossfadeAnimationSpec) } /** * viewer手势对象 */ class ViewerGestureScope( // 点击事件 var onTap: (Offset) -> Unit = {}, // 双击事件 var onDoubleTap: (Offset) -> Unit = {}, // 长按事件 var onLongPress: (Offset) -> Unit = {}, ) /** * viewer传入的Compose数据类型参数 * @property content [@androidx.compose.runtime.Composable] [@kotlin.ExtensionFunctionType] Function1 * @constructor */ class ComposeModel( private val content: @Composable ComposeModel.() -> Unit = {} ) { internal var intrinsicSize by mutableStateOf(IntSize.Zero) fun updateIntrinsicSize(size: IntSize) { intrinsicSize = size } @Composable fun PoseContent() { content() } } /** * model支持Painter、ImageBitmap、ImageVector、ImageDecoder、ComposeModel */ @Composable fun ImageViewer( // 修改参数 modifier: Modifier = Modifier, // 图片数据 model: Any?, // viewer状态 state: ImageViewerState = rememberViewerState(), // 检测手势 detectGesture: ViewerGestureScope.() -> Unit = {}, // 超出容器是否显示 boundClip: Boolean = true, // 调试模式 debugMode: Boolean = false, ) { val viewerGestureScope = remember { ViewerGestureScope() } detectGesture.invoke(viewerGestureScope) val scope = rememberCoroutineScope() // 触摸时中心位置 var centroid by remember { mutableStateOf(Offset.Zero) } // 减速运动动画曲线 val decay = remember { FloatExponentialDecaySpec(2f).generateDecayAnimationSpec() } var velocityTracker = remember { VelocityTracker() } // 记录触摸事件中手指的个数 var eventChangeCount by remember { mutableStateOf(0) } // 最后一次偏移运动 var lastPan by remember { mutableStateOf(Offset.Zero) } // 手势实时的偏移范围 var boundX by remember { mutableStateOf(0F) } var boundY by remember { mutableStateOf(0F) } // 最大缩放率,双击的时候会放大到这个值 var maxScale by remember { mutableStateOf(1F) } // 最大显示缩放率,缩放率超过这个值后,手势结束了就会自动恢复到这个值 val maxDisplayScale by remember { derivedStateOf { maxScale * MAX_SCALE_RATE } } // 目标偏移量 var desX by remember { mutableStateOf(0F) } var desY by remember { mutableStateOf(0F) } // 目标缩放率 var desScale by remember { mutableStateOf(1F) } // 缩放率修改前的值 var fromScale by remember { mutableStateOf(1F) } // 计算边界使用的缩放率 var boundScale by remember { mutableStateOf(1F) } // 目标旋转角度 var desRotation by remember { mutableStateOf(0F) } // 要增加的旋转角度 var rotate by remember { mutableStateOf(0F) } // 要增加的放大倍率 var zoom by remember { mutableStateOf(1F) } // 两个手指的距离 var fingerDistanceOffset by remember { mutableStateOf(Offset.Zero) } // 同步des的参数,在gallery的图片切换时,缩小后仍然接收手势指令,所以需要同步缩小后的参数 fun asyncDesParams() { desX = state.offsetX.value desY = state.offsetY.value desScale = state.scale.value desRotation = state.rotation.value } LaunchedEffect(key1 = state.resetTimeStamp) { asyncDesParams() } val gesture = remember { RawGesture( onTap = viewerGestureScope.onTap, onDoubleTap = viewerGestureScope.onDoubleTap, onLongPress = viewerGestureScope.onLongPress, gestureStart = { if (state.allowGestureInput) { eventChangeCount = 0 velocityTracker = VelocityTracker() scope.launch { state.offsetX.stop() state.offsetY.stop() state.offsetX.updateBounds(null, null) state.offsetY.updateBounds(null, null) } asyncDesParams() } }, gestureEnd = { transformOnly -> // transformOnly记录手势事件中是否有位移,如果只是点击或双击,会返回false // 如果正在动画中,就不要执行后续动作,如:reset指令执行时 if (transformOnly && !state.isRunning() && state.allowGestureInput) { // 处理加速度添加的点为空的情况 var velocity = try { velocityTracker.calculateVelocity() } catch (e: Exception) { e.printStackTrace() null } // 如果缩放比小于1,要自动回到1 // 如果缩放比大于最大显示缩放比,就设置回去,并且避免加速度 val scale = when { state.scale.value < 1 -> 1F state.scale.value > maxDisplayScale -> { velocity = null maxDisplayScale } else -> null } // 如果此时位移超出范围,就动画回范围内 // 如果没超出范围,就设置animate的范围,然后执行抛掷动画 scope.launch { if (inBound(state.offsetX.value, boundX) && velocity != null) { val vx = sameDirection(lastPan.x, velocity.x) state.offsetX.updateBounds(-boundX, boundX) state.offsetX.animateDecay(vx, decay) } else { val targetX = if (scale != maxDisplayScale) { limitToBound(state.offsetX.value, boundX) } else { panTransformAndScale( offset = state.offsetX.value, center = centroid.x, bh = state.containerSize.width.toFloat(), uh = state.defaultSize.width.toFloat(), fromScale = state.scale.value, toScale = scale, ) } state.offsetX.animateTo(targetX) } } scope.launch { if (inBound(state.offsetY.value, boundY) && velocity != null) { val vy = sameDirection(lastPan.y, velocity.y) state.offsetY.updateBounds(-boundY, boundY) state.offsetY.animateDecay(vy, decay) } else { val targetY = if (scale != maxDisplayScale) { limitToBound(state.offsetY.value, boundY) } else { panTransformAndScale( offset = state.offsetY.value, center = centroid.y, bh = state.containerSize.height.toFloat(), uh = state.defaultSize.height.toFloat(), fromScale = state.scale.value, toScale = scale, ) } state.offsetY.animateTo(targetY) } } scope.launch { state.rotation.animateTo(0F) } scale?.let { scope.launch { state.scale.animateTo(scale) } } } }, ) { center, pan, _zoom, _rotate, event -> // 当禁止手势输入时 if (!state.allowGestureInput) return@RawGesture true // 这里只记录最大手指数 if (event.changes.size > eventChangeCount) eventChangeCount = event.changes.size // 如果手指数从多个变成一个,就结束本次手势操作 if (eventChangeCount > event.changes.size) return@RawGesture false rotate = _rotate zoom = _zoom // 如果是双指的情况下,手指距离小于一定值时,缩放和旋转的值会很离谱,所以在这种极端情况下就不要处理缩放和旋转了 if (event.changes.size == 2) { fingerDistanceOffset = event.changes[0].position - event.changes[1].position if ( fingerDistanceOffset.x.absoluteValue < MIN_GESTURE_FINGER_DISTANCE && fingerDistanceOffset.y.absoluteValue < MIN_GESTURE_FINGER_DISTANCE ) { rotate = 0F zoom = 1F } } // 上一次的偏移量 lastPan = pan // 记录手势的中点 centroid = center // 记录当前缩放比 fromScale = desScale // 目标放大倍率 desScale *= zoom // 检查最小放大倍率 if (desScale < MIN_SCALE) desScale = MIN_SCALE // 计算边界,如果目标缩放值超过最大显示缩放值,边界就要用最大缩放值来计算,否则手势结束时会导致无法归位 boundScale = if (desScale > maxDisplayScale) maxDisplayScale else desScale boundX = getBound(boundScale * state.defaultSize.width, state.containerSize.width.toFloat()) boundY = getBound( boundScale * state.defaultSize.height, state.containerSize.height.toFloat() ) desX = panTransformAndScale( offset = desX, center = center.x, bh = state.containerSize.width.toFloat(), uh = state.defaultSize.width.toFloat(), fromScale = fromScale, toScale = desScale, ) + pan.x // 如果手指数1,就是拖拽,拖拽受范围限制 // 如果手指数大于1,即有缩放事件,则支持中心点放大 if (eventChangeCount == 1) desX = limitToBound(desX, boundX) desY = panTransformAndScale( offset = desY, center = center.y, bh = state.containerSize.height.toFloat(), uh = state.defaultSize.height.toFloat(), fromScale = fromScale, toScale = desScale, ) + pan.y if (eventChangeCount == 1) desY = limitToBound(desY, boundY) if (desScale < 1) desRotation += rotate velocityTracker.addPosition( event.changes[0].uptimeMillis, Offset(desX, desY), ) if (!state.isRunning()) scope.launch { state.scale.snapTo(desScale) state.offsetX.snapTo(desX) state.offsetY.snapTo(desY) state.rotation.snapTo(desRotation) } // 这里判断是否已运动到边界,如果到了边界,就不消费事件,让上层界面获取到事件 val onLeft = desX >= boundX val onRight = desX <= -boundX val reachSide = !(onLeft && pan.x > 0) && !(onRight && pan.x < 0) && !(onLeft && onRight) if (reachSide || state.scale.value < 1) { event.changes.fastForEach { if (it.positionChanged()) { it.consumeAllChanges() } } } // 返回true,继续下一次手势 return@RawGesture true } } val sizeChange: suspend (SizeChangeContent) -> Unit = { content -> maxScale = content.maxScale state.defaultSize = content.defaultSize state.containerSize = content.containerSize state.maxScale = content.maxScale if (state.fromSaver) { state.fromSaver = false state.fixToBound() } } Box(modifier = modifier) { /** * 将挂载信息通知到state */ val onMounted: () -> Unit = { scope.launch { state.mountedFlow.emit(true) } } /** * 根据不同类型的model进行不同的渲染 */ when (model) { is Painter, is ImageVector, is ImageBitmap, is ComposeModel, -> { ImageComposeOrigin( model = model, scale = state.scale.value, offsetX = state.offsetX.value, offsetY = state.offsetY.value, rotation = state.rotation.value, gesture = gesture, onSizeChange = sizeChange, onMounted = onMounted, boundClip = boundClip, crossfadeAnimationSpec = state.crossfadeAnimationSpec, ) } is ImageDecoder -> { ImageComposeCanvas( imageDecoder = model, scale = state.scale.value, offsetX = state.offsetX.value, offsetY = state.offsetY.value, rotation = state.rotation.value, gesture = gesture, onSizeChange = sizeChange, onMounted = onMounted, boundClip = boundClip, crossfadeAnimationSpec = state.crossfadeAnimationSpec, ) } } /** * 调试模式 */ if (debugMode) { Box( modifier = Modifier .fillMaxSize() .zIndex(10F) ) { if (model != null) { Text(text = "Model -> ON", Modifier.background(Color.White)) } Box( modifier = Modifier .graphicsLayer { translationX = centroid.x - 6.dp.toPx() translationY = centroid.y - 6.dp.toPx() } .clip(CircleShape) .background(Color.Red.copy(0.4f)) .size(12.dp) ) } } } } /** * 重写事件监听方法 */ suspend fun PointerInputScope.detectTransformGestures( panZoomLock: Boolean = false, gestureStart: () -> Unit = {}, gestureEnd: (Boolean) -> Unit = {}, onTap: (Offset) -> Unit = {}, onDoubleTap: (Offset) -> Unit = {}, onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float, event: PointerEvent) -> Boolean, ) { var lastReleaseTime = 0L var scope: CoroutineScope? = null forEachGesture { awaitPointerEventScope { var rotation = 0f var zoom = 1f var pan = Offset.Zero var pastTouchSlop = false val touchSlop = viewConfiguration.touchSlop var lockedToPanZoom = false awaitFirstDown(requireUnconsumed = false) val t0 = System.currentTimeMillis() var releasedEvent: PointerEvent? = null var moveCount = 0 // 这里开始事件 gestureStart() do { val event = awaitPointerEvent() if (event.type == PointerEventType.Release) releasedEvent = event if (event.type == PointerEventType.Move) moveCount++ val canceled = event.changes.fastAny { it.positionChangeConsumed() } if (!canceled) { val zoomChange = event.calculateZoom() val rotationChange = event.calculateRotation() val panChange = event.calculatePan() if (!pastTouchSlop) { zoom *= zoomChange rotation += rotationChange pan += panChange val centroidSize = event.calculateCentroidSize(useCurrent = false) val zoomMotion = abs(1 - zoom) * centroidSize val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f) val panMotion = pan.getDistance() if (zoomMotion > touchSlop || rotationMotion > touchSlop || panMotion > touchSlop ) { pastTouchSlop = true lockedToPanZoom = panZoomLock && rotationMotion < touchSlop } } if (pastTouchSlop) { val centroid = event.calculateCentroid(useCurrent = false) val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange if (effectiveRotation != 0f || zoomChange != 1f || panChange != Offset.Zero ) { if (!onGesture( centroid, panChange, zoomChange, effectiveRotation, event ) ) break } } } } while (!canceled && event.changes.fastAny { it.pressed }) var t1 = System.currentTimeMillis() val dt = t1 - t0 val dlt = t1 - lastReleaseTime if (moveCount == 0) releasedEvent?.let { e -> if (e.changes.isEmpty()) return@let val offset = e.changes.first().position if (dlt < 272) { t1 = 0L scope?.cancel() onDoubleTap(offset) } else if (dt < 200) { scope = MainScope() scope?.launch(Dispatchers.Main) { delay(272) onTap(offset) } } lastReleaseTime = t1 } // 这里是事件结束 gestureEnd(moveCount != 0) } } } /** * 让后一个数与前一个数的符号保持一致 * @param a Float * @param b Float * @return Float */ fun sameDirection(a: Float, b: Float): Float { return if (a > 0) { if (b < 0) { b.absoluteValue } else { b } } else { if (b > 0) { -b } else { b } } } /** * 获取移动边界 */ fun getBound(rw: Float, bw: Float): Float { return if (rw > bw) { var xb = (rw - bw).div(2) if (xb < 0) xb = 0F xb } else { 0F } } /** * 判断位移是否在边界内 */ fun inBound(offset: Float, bound: Float): Boolean { return if (offset > 0) { offset < bound } else if (offset < 0) { offset > -bound } else { true } } /** * 把位移限制在边界内 */ fun limitToBound(offset: Float, bound: Float): Float { return when { offset > bound -> { bound } offset < -bound -> { -bound } else -> { offset } } } /** * 追踪缩放过程中的中心点 */ fun panTransformAndScale( offset: Float, center: Float, bh: Float, uh: Float, fromScale: Float, toScale: Float, ): Float { val srcH = uh * fromScale val desH = uh * toScale val gapH = (bh - uh) / 2 val py = when { uh >= bh -> { val upy = (uh * fromScale - uh).div(2) (upy - offset + center) / (fromScale * uh) } srcH > bh || bh > uh -> { val upy = (srcH - uh).div(2) (upy - gapH - offset + center) / (fromScale * uh) } else -> { val upy = -(bh - srcH).div(2) (upy - offset + center) / (fromScale * uh) } } return when { uh >= bh -> { val upy = (uh * toScale - uh).div(2) upy + center - py * toScale * uh } desH > bh -> { val upy = (desH - uh).div(2) upy - gapH + center - py * toScale * uh } else -> { val upy = -(bh - desH).div(2) upy + center - py * desH } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/DynamicDetailActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.mobile.screen.DynamicDetailScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import io.github.oshai.kotlinlogging.KotlinLogging class DynamicDetailActivity : ComponentActivity() { companion object { private val logger = KotlinLogging.logger { } fun actionStart(context: Context, dynamicId: String) { logger.info { "actionStart: dynamicId=$dynamicId" } context.startActivity( Intent(context, DynamicDetailActivity::class.java).apply { putExtra("dynamicId", dynamicId) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVMobileTheme { DynamicDetailScreen() } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/FavoriteActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import dev.aaa1115910.bv.mobile.screen.FavoriteScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme class FavoriteActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val windowSize = calculateWindowSizeClass(this) BVMobileTheme { FavoriteScreen( windowSize = windowSize ) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/FollowingSeasonActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import dev.aaa1115910.bv.mobile.screen.FollowingSeasonScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme class FollowingSeasonActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val windowSize = calculateWindowSizeClass(this) BVMobileTheme { FollowingSeasonScreen( windowSize = windowSize ) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/FollowingUserActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.mobile.screen.FollowingUserScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme class FollowingUserActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVMobileTheme { FollowingUserScreen() } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/HistoryActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import dev.aaa1115910.bv.mobile.screen.HistoryScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme class HistoryActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val windowSize = calculateWindowSizeClass(this) BVMobileTheme { HistoryScreen( windowSize = windowSize ) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/IntentHandlerActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.os.Bundle import androidx.activity.ComponentActivity import dev.aaa1115910.bv.entity.BvScheme import io.github.oshai.kotlinlogging.KotlinLogging class IntentHandlerActivity : ComponentActivity() { companion object { private val logger = KotlinLogging.logger { } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val uri = intent.data when (uri?.host) { BvScheme.QrToken.HOST -> QrTokenResultActivity.launch(this, uri) else -> { logger.info { "unknown uri: $uri" } finish() } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/LoginActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.mobile.screen.LoginScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVMobileTheme { LoginScreen() } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/MainActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import dev.aaa1115910.bv.mobile.screen.MobileMainScreen import dev.aaa1115910.bv.mobile.screen.RegionBlockScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.NetworkUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { var keepSplashScreen = true installSplashScreen().apply { setKeepOnScreenCondition { keepSplashScreen } } super.onCreate(savedInstanceState) setContent { val scope = rememberCoroutineScope() var isCheckingNetwork by remember { mutableStateOf(true) } var isMainlandChina by remember { mutableStateOf(false) } LaunchedEffect(Unit) { scope.launch(Dispatchers.IO) { isMainlandChina = NetworkUtil.isMainlandChina() isCheckingNetwork = false keepSplashScreen = false } } BVMobileTheme { if (isCheckingNetwork) { // 避免提前加载内容 // } else if (isMainlandChina) { // RegionBlockScreen() } else { MobileMainScreen() } } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/QrTokenResultActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.mobile.screen.QrTokenResultScreen import io.github.oshai.kotlinlogging.KotlinLogging class QrTokenResultActivity : ComponentActivity() { companion object { private val logger = KotlinLogging.logger { } fun launch(context: Context, uri: Uri) { logger.info { "launch QrTokenResultActivity: uri=$uri" } context.startActivity( Intent(context, QrTokenResultActivity::class.java).apply { putExtra("uri", uri) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { QrTokenResultScreen() } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/SettingsActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.mobile.screen.settings.SettingsScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme class SettingsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVMobileTheme { SettingsScreen() } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/UserSpaceActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.mobile.screen.UserSpaceScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme class UserSpaceActivity : ComponentActivity() { companion object { fun actionStart(context: Context, mid: Long, name: String) { context.startActivity( Intent(context, UserSpaceActivity::class.java).apply { putExtra("mid", mid) putExtra("name", name) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVMobileTheme { UserSpaceScreen() } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/activities/VideoPlayerActivity.kt ================================================ package dev.aaa1115910.bv.mobile.activities import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.lifecycle.lifecycleScope import com.kuaishou.akdanmaku.render.SimpleRenderer import com.kuaishou.akdanmaku.ui.DanmakuPlayer import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.biliapi.http.BiliHttpApi import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.PlayerType import dev.aaa1115910.bv.mobile.screen.VideoPlayerScreen import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.player.VideoPlayerOptions import dev.aaa1115910.bv.player.impl.exo.ExoPlayerFactory import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.CommentViewModel import dev.aaa1115910.bv.viewmodel.VideoPlayerV3ViewModel import dev.aaa1115910.bv.viewmodel.video.VideoDetailViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.androidx.viewmodel.ext.android.viewModel class VideoPlayerActivity : ComponentActivity() { companion object { fun actionStart( context: Context, aid: Long, //cid: Long, fromSeason: Boolean = false, epid: Int? = null, seasonId: Int? = null, ) { context.startActivity( Intent(context, VideoPlayerActivity::class.java).apply { putExtra("aid", aid) //putExtra("cid", cid) putExtra("fromSeason", fromSeason) epid?.let { putExtra("epid", it) } seasonId?.let { putExtra("seasonId", it) } } ) } } private val playerViewModel: VideoPlayerV3ViewModel by viewModel() private val commentViewModel: CommentViewModel by viewModel() private val videoDetailViewModel: VideoDetailViewModel by viewModel() private val logger = KotlinLogging.logger {} @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initVideoPlayer() initDanmakuPlayer() setContent { val windowSizeClass = calculateWindowSizeClass(this) BVMobileTheme { VideoPlayerScreen( windowSizeClass = windowSizeClass ) } } } private fun initVideoPlayer() { if (playerViewModel.videoPlayer != null) return logger.fInfo { "initVideoPlayer" } val options = VideoPlayerOptions( userAgent = when (Prefs.apiType) { ApiType.Web -> getString(R.string.video_player_user_agent_http) ApiType.App -> getString(R.string.video_player_user_agent_client) }, referer = when (Prefs.apiType) { ApiType.Web -> getString(R.string.video_player_referer) ApiType.App -> null } ) val videoPlayer = when (Prefs.playerType) { PlayerType.Media3 -> ExoPlayerFactory().create(this, options) } playerViewModel.videoPlayer = videoPlayer //TODO 还没处理旋转后的一些判断,就先放这了 parseIntent() } private fun initDanmakuPlayer() { if (playerViewModel.danmakuPlayer != null) return logger.fInfo { "initDanmakuPlayer" } playerViewModel.danmakuPlayer = DanmakuPlayer(SimpleRenderer()) } private fun parseIntent() { var aid = intent.getLongExtra("aid", 0) var cid = intent.getLongExtra("cid", 0) val fromSeason = intent.getBooleanExtra("fromSeason", false) val epid = intent.getIntExtra("epid", 0) val seasonId = intent.getIntExtra("seasonId", 0) lifecycleScope.launch(Dispatchers.IO) { if (aid == 0L && cid == 0L) { runCatching { val acid = BiliHttpApi.getAidCidByEpid(epid)!! aid = acid.first cid = acid.second }.onFailure { logger.fInfo { "get avid & cid by epid failed: ${it.stackTraceToString()}" } withContext(Dispatchers.Main) { it.message?.toast(this@VideoPlayerActivity) } } } commentViewModel.commentType = 1 commentViewModel.commentId = aid runCatching { videoDetailViewModel.loadDetail(aid, fromSeason) }.onFailure { withContext(Dispatchers.Main) { it.message?.toast(this@VideoPlayerActivity) } } runCatching { playerViewModel.fromSeason = fromSeason playerViewModel.loadPlayUrl( avid = videoDetailViewModel.videoDetail?.aid ?: 0, cid = videoDetailViewModel.videoDetail?.cid ?: 0, epid = epid.takeIf { it != 0 }, seasonId = seasonId.takeIf { it != 0 } ) }.onFailure { withContext(Dispatchers.Main) { it.message?.toast(this@VideoPlayerActivity) } } } } override fun onDestroy() { super.onDestroy() playerViewModel.videoPlayer?.release() } override fun onPause() { super.onPause() playerViewModel.videoPlayer?.pause() playerViewModel.danmakuPlayer?.pause() } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/home/SearchBar.kt ================================================ package dev.aaa1115910.bv.mobile.component.home import android.content.Context import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import dev.aaa1115910.bv.BVApp import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable fun HomeSearchTopBarCompact( modifier: Modifier = Modifier, query: String, expanded: Boolean, selectedTabIndex: Int, onQueryChange: (String) -> Unit, onExpandedChange: (Boolean) -> Unit, onOpenNavDrawer: () -> Unit, onChangeTabIndex: (Int) -> Unit, onSwitchUser: () -> Unit ) { val context = LocalContext.current var currentTab by remember { mutableStateOf(HomeTab.Recommend) } val searchBarHorizontalPadding by animateDpAsState( targetValue = if (expanded) 0.dp else 16.dp, label = "search bar horizontal padding" ) Box( modifier = modifier, contentAlignment = Alignment.TopCenter ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = searchBarHorizontalPadding) .zIndex(2f), horizontalArrangement = Arrangement.Center ) { HomeSearchBar( modifier = Modifier.fillMaxWidth(), query = query, expanded = expanded, onQueryChange = onQueryChange, onExpandedChange = onExpandedChange, onOpenNavDrawer = onOpenNavDrawer, onSwitchUser = onSwitchUser, onSearch = {} ) } TabRow( modifier = Modifier .padding(top = 100.dp) .zIndex(1f), selectedTabIndex = selectedTabIndex ) { HomeTab.entries.forEachIndexed { index, tab -> Tab( selected = selectedTabIndex == index, onClick = { onChangeTabIndex(index) currentTab = tab }, text = { Text( text = tab.getDisplayName(context), maxLines = 2, overflow = TextOverflow.Ellipsis ) } ) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeSearchTopBarExpanded( modifier: Modifier = Modifier, query: String, active: Boolean, onQueryChange: (String) -> Unit, onActiveChange: (Boolean) -> Unit, ) { Box( modifier .semantics { isTraversalGroup = true } .zIndex(1f) .fillMaxWidth() ) { Spacer( modifier = Modifier .fillMaxWidth() .height(60.dp) .background(MaterialTheme.colorScheme.surface) ) Row( modifier = Modifier, verticalAlignment = Alignment.Top ) { DockedSearchBar( modifier = Modifier.padding(vertical = 3.dp, horizontal = 16.dp), inputField = { SearchBarDefaults.InputField( query = query, onQueryChange = onQueryChange, onSearch = {}, expanded = active, onExpandedChange = onActiveChange ) }, expanded = active, onExpandedChange = onActiveChange ) { Text("???") } val titles = listOf("Tab 1", "Tab 2", "Tab 3 with lots of text") var state by remember { mutableStateOf(0) } Box( modifier = Modifier .height(62.dp) .fillMaxWidth(), contentAlignment = Alignment.BottomCenter ) { TabRow( selectedTabIndex = state ) { titles.forEachIndexed { index, title -> Tab( selected = state == index, onClick = { state = index }, text = { Text( text = title, maxLines = 2, overflow = TextOverflow.Ellipsis ) } ) } } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun HomeSearchBar( modifier: Modifier = Modifier, query: String, expanded: Boolean, onQueryChange: (String) -> Unit, onExpandedChange: (Boolean) -> Unit, onOpenNavDrawer: () -> Unit, onSwitchUser: () -> Unit, onSearch: (String) -> Unit, ) { SearchBar( modifier = modifier, inputField = { SearchBarDefaults.InputField( query = query, onQueryChange = onQueryChange, onSearch = { onSearch(it) onExpandedChange(false) }, expanded = expanded, onExpandedChange = onExpandedChange, leadingIcon = { if (!expanded) { IconButton(onClick = onOpenNavDrawer) { Icon(imageVector = Icons.Default.Menu, contentDescription = null) } } else { Icon(imageVector = Icons.Default.Search, contentDescription = null) } }, trailingIcon = { if (!expanded) { IconButton(onClick = onSwitchUser) { Icon(imageVector = Icons.Default.Person, contentDescription = null) } } }, ) }, expanded = expanded, onExpandedChange = onExpandedChange, ) { } } enum class HomeTab(private val strRes: Int) { Recommend(R.string.home_tab_rcmd), Popular(R.string.home_tab_popular); fun getDisplayName(context: Context = BVApp.context) = context.getString(strRes) } @Preview @Composable private fun HomeSearchTopBarCompactPreview() { var query by remember { mutableStateOf("") } val active by remember { derivedStateOf { query != "" } } BVMobileTheme { Column { HomeSearchTopBarCompact( query = query, expanded = false, selectedTabIndex = 0, onQueryChange = { query = it }, onExpandedChange = { }, onOpenNavDrawer = { }, onChangeTabIndex = { }, onSwitchUser = { } ) Text(text = "query: $query") Text(text = "active: $active") } } } @Preview(device = "spec:parent=pixel_5,orientation=landscape") @Composable private fun HomeSearchTopBarExpandedPreview() { BVMobileTheme { HomeSearchTopBarExpanded( query = "Search", active = false, onQueryChange = { }, onActiveChange = { }, ) } } @Preview @OptIn(ExperimentalMaterial3Api::class) @Composable fun Demo(modifier: Modifier = Modifier) { var text by rememberSaveable { mutableStateOf("") } var expanded by rememberSaveable { mutableStateOf(false) } Box( Modifier .fillMaxSize() .semantics { isTraversalGroup = true } ) { SearchBar( modifier = Modifier .align(Alignment.TopCenter) .semantics { traversalIndex = 0f }, inputField = { SearchBarDefaults.InputField( query = text, onQueryChange = { text = it }, onSearch = { expanded = false }, expanded = expanded, onExpandedChange = { expanded = it }, placeholder = { Text("Hinted search text") }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) }, ) }, expanded = expanded, onExpandedChange = { expanded = it }, ) { Column( Modifier .verticalScroll(rememberScrollState()) .imePadding() ) { repeat(40) { idx -> val resultText = "Suggestion $idx" ListItem( headlineContent = { Text(resultText) }, supportingContent = { Text("Additional info") }, leadingContent = { Icon(Icons.Filled.Star, contentDescription = null) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier .clickable { text = resultText expanded = false } .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) ) } } } LazyColumn( contentPadding = PaddingValues(start = 16.dp, top = 72.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.semantics { traversalIndex = 1f }, ) { val list = List(100) { "Text $it" } items(count = list.size) { Text( text = list[it], modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/home/UserDialog.kt ================================================ package dev.aaa1115910.bv.mobile.component.home import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.PersonRemove import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.SupervisorAccount import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import dev.aaa1115910.bv.entity.db.UserDB import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.ifElse @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun UserDialog( modifier: Modifier = Modifier, show: Boolean, windowWidthSizeClass: WindowWidthSizeClass = WindowWidthSizeClass.Compact, currentUser: UserDB?, userList: List, onHideDialog: () -> Unit, onSwitchUser: (UserDB) -> Unit, onAddUser: () -> Unit, onDeleteUser: (UserDB) -> Unit, onOpenFollowingUser: () -> Unit, onOpenHistory: () -> Unit, onOpenFavorite: () -> Unit, onOpenFollowingPgc: () -> Unit, onOpenToView: () -> Unit, onOpenSettings: () -> Unit, ) { if (show) { Box( modifier = modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.4f)) .clickable( interactionSource = null, indication = null, onClick = onHideDialog ) ) { UserDialogContent( modifier = Modifier .padding(horizontal = 16.dp, vertical = 80.dp) .ifElse( { windowWidthSizeClass != WindowWidthSizeClass.Compact }, Modifier.width(500.dp) ) .clip(MaterialTheme.shapes.extraLarge) .align(Alignment.TopCenter), currentUser = currentUser, userList = userList, onClose = onHideDialog, onSwitchUser = onSwitchUser, onAddUser = onAddUser, onDeleteUser = onDeleteUser, onOpenFollowingUser = onOpenFollowingUser, onOpenHistory = onOpenHistory, onOpenFavorite = onOpenFavorite, onOpenFollowingPgc = onOpenFollowingPgc, onOpenToView = onOpenToView, onOpenSettings = onOpenSettings ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun UserDialogContent( modifier: Modifier = Modifier, currentUser: UserDB?, userList: List, onClose: () -> Unit, onSwitchUser: (UserDB) -> Unit, onAddUser: () -> Unit, onDeleteUser: (UserDB) -> Unit, onOpenFollowingUser: () -> Unit, onOpenHistory: () -> Unit, onOpenFavorite: () -> Unit, onOpenFollowingPgc: () -> Unit, onOpenToView: () -> Unit, onOpenSettings: () -> Unit, ) { var expandUserManager by remember { mutableStateOf(false) } Box( modifier = modifier .background(MaterialTheme.colorScheme.surfaceVariant) ) { Column { CenterAlignedTopAppBar( title = { Text(text = "Bug Video") }, navigationIcon = { IconButton(onClick = onClose) { Icon(imageVector = Icons.Default.Close, contentDescription = null) } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ), windowInsets = WindowInsets(0, 0, 0, 0), ) Column( modifier = Modifier .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(2.dp) ) { if (currentUser == null) { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), shape = MaterialTheme.shapes.extraLarge ) { ListItem( modifier = Modifier.clickable { onAddUser() }, headlineContent = { Text(text = "登录") }, leadingContent = { Icon( imageVector = Icons.Outlined.PersonAdd, contentDescription = null ) } ) } } else { Column { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), shape = RoundedCornerShape( topStart = MaterialTheme.shapes.extraLarge.topStart, topEnd = MaterialTheme.shapes.extraLarge.topEnd, bottomStart = MaterialTheme.shapes.extraSmall.bottomStart, bottomEnd = MaterialTheme.shapes.extraSmall.bottomEnd ) ) { UserItem( username = currentUser.username, avatar = currentUser.avatar, uid = currentUser.uid, expandUserManager = expandUserManager, onClick = {}, onExpandUserManagerChange = { expandUserManager = it } ) } AnimatedVisibility( visible = expandUserManager, enter = expandVertically(), exit = shrinkVertically() ) { Card( modifier = Modifier.padding(top = 2.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), shape = MaterialTheme.shapes.extraSmall ) { Column { userList .filter { it != currentUser } .forEach { user -> UserItem( username = user.username, avatar = user.avatar, uid = user.uid, onClick = { onSwitchUser(user) onClose() } ) } } ListItem( modifier = Modifier.clickable { onAddUser() }, headlineContent = { Text(text = "添加其他账号") }, leadingContent = { Icon( modifier = Modifier .width(40.dp) .scale(scaleX = -1f, scaleY = 1f), imageVector = Icons.Outlined.PersonAdd, contentDescription = null ) } ) ListItem( modifier = Modifier.clickable { onDeleteUser(currentUser) }, headlineContent = { Text(text = "移除此设备上的账号") }, leadingContent = { Icon( modifier = Modifier .width(40.dp), imageVector = Icons.Outlined.PersonRemove, contentDescription = null ) } ) } } } Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), shape = RoundedCornerShape( topStart = MaterialTheme.shapes.extraSmall.topStart, topEnd = MaterialTheme.shapes.extraSmall.topEnd, bottomStart = MaterialTheme.shapes.extraLarge.bottomStart, bottomEnd = MaterialTheme.shapes.extraLarge.bottomEnd ) ) { Column { ListItem( modifier = Modifier.clickable { onOpenFollowingUser() }, headlineContent = { Text(text = "我的关注") }, leadingContent = { Icon( imageVector = Icons.Rounded.SupervisorAccount, contentDescription = null ) } ) ListItem( modifier = Modifier.clickable { onOpenHistory() }, headlineContent = { Text(text = "历史记录") }, leadingContent = { Icon( imageVector = Icons.Rounded.History, contentDescription = null ) } ) ListItem( modifier = Modifier.clickable { onOpenFavorite() }, headlineContent = { Text(text = "我的收藏") }, leadingContent = { Icon( imageVector = Icons.Rounded.Favorite, contentDescription = null ) } ) ListItem( modifier = Modifier.clickable { onOpenFollowingPgc() }, headlineContent = { Text(text = "我的追番") }, leadingContent = { Icon( imageVector = Icons.Rounded.SupervisorAccount, contentDescription = null ) } ) ListItem( modifier = Modifier.clickable { onOpenToView() }, headlineContent = { Text(text = "稍后再看") }, leadingContent = { Icon( imageVector = Icons.Rounded.SupervisorAccount, contentDescription = null ) } ) } } } ListItem( modifier = Modifier.clickable { onOpenSettings() }, headlineContent = { Text(text = "设置") }, leadingContent = { Icon( imageVector = Icons.Rounded.Settings, contentDescription = null ) }, colors = ListItemDefaults.colors( containerColor = Color.Transparent ) ) Spacer(modifier = Modifier.height(16.dp)) } } } } @Preview @Preview(uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) @Composable private fun UserDialogContentPreview() { var currentUser by remember { mutableStateOf(UserDB(-1, -1, "", "", "")) } val userList = remember { mutableStateListOf() } LaunchedEffect(Unit) { for (i in 0..5) { userList.add( UserDB( id = i, uid = 100000L + i, username = "User $i", avatar = "", auth = "" ) ) } currentUser = userList[1] } BVMobileTheme { UserDialogContent( currentUser = currentUser, userList = userList, onClose = {}, onSwitchUser = {}, onAddUser = {}, onDeleteUser = {}, onOpenFollowingUser = {}, onOpenHistory = {}, onOpenFavorite = {}, onOpenFollowingPgc = {}, onOpenToView = {}, onOpenSettings = {} ) } } @Preview @Composable private fun UserDialogContentLoginRequirePreview() { var currentUser by remember { mutableStateOf(null) } val userList = remember { mutableStateListOf() } BVMobileTheme { UserDialogContent( currentUser = currentUser, userList = userList, onClose = {}, onSwitchUser = {}, onAddUser = {}, onDeleteUser = {}, onOpenFollowingUser = {}, onOpenHistory = {}, onOpenFavorite = {}, onOpenFollowingPgc = {}, onOpenToView = {}, onOpenSettings = {} ) } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Preview @Composable private fun UserDialogPreview() { var currentUser by remember { mutableStateOf(UserDB(-1, -1, "", "", "")) } val userList = remember { mutableStateListOf() } LaunchedEffect(Unit) { for (i in 0..5) { userList.add( UserDB( id = i, uid = 100000L + i, username = "User $i", avatar = "", auth = "" ) ) } currentUser = userList[1] } BVMobileTheme { UserDialog( show = true, currentUser = currentUser, userList = userList, onHideDialog = {}, onSwitchUser = {}, onAddUser = {}, onDeleteUser = {}, onOpenFollowingUser = {}, onOpenHistory = {}, onOpenFavorite = {}, onOpenFollowingPgc = {}, onOpenToView = {}, onOpenSettings = {} ) } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Preview(device = "spec:width=1280dp,height=800dp,dpi=240") @Composable private fun UserDialogWidthScreenPreview() { val windowSize = WindowSizeClass.calculateFromSize(DpSize(1280.dp, 800.dp)) var currentUser by remember { mutableStateOf(UserDB(-1, -1, "", "", "")) } val userList = remember { mutableStateListOf() } LaunchedEffect(Unit) { for (i in 0..5) { userList.add( UserDB( id = i, uid = 100000L + i, username = "User $i", avatar = "", auth = "" ) ) } currentUser = userList[1] } BVMobileTheme { UserDialog( show = true, windowWidthSizeClass = windowSize.widthSizeClass, currentUser = currentUser, userList = userList, onHideDialog = {}, onSwitchUser = {}, onAddUser = {}, onDeleteUser = {}, onOpenFollowingUser = {}, onOpenHistory = {}, onOpenFavorite = {}, onOpenFollowingPgc = {}, onOpenToView = {}, onOpenSettings = {} ) } } @Composable private fun UserItem( modifier: Modifier = Modifier, username: String, avatar: String, uid: Long, expandUserManager: Boolean = false, onClick: () -> Unit, onExpandUserManagerChange: ((Boolean) -> Unit)? = null ) { ListItem( modifier = modifier .clickable { onClick() }, headlineContent = { Text(text = username) }, supportingContent = { Text(text = "$uid") }, leadingContent = { AsyncImage( modifier = Modifier .size(40.dp) .clip(CircleShape) .background(Color.Gray), model = avatar, contentDescription = null, contentScale = ContentScale.FillBounds ) }, trailingContent = (@Composable { if (expandUserManager) { IconButton(onClick = { onExpandUserManagerChange?.invoke(false) }) { Icon(imageVector = Icons.Default.ArrowDropUp, contentDescription = null) } } else { IconButton(onClick = { onExpandUserManagerChange?.invoke(true) }) { Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null) } } }).takeIf { onExpandUserManagerChange != null } ) } @Preview @Composable private fun UserItemPreview() { BVMobileTheme { UserItem( username = "username", avatar = "", uid = 123456, onClick = {} ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/home/dynamic/DynamicItem.kt ================================================ package dev.aaa1115910.bv.mobile.component.home.dynamic import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Comment import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import coil.compose.rememberAsyncImagePainter import com.origeek.imageViewer.previewer.ImagePreviewerState import com.origeek.imageViewer.previewer.TransformImageView import com.origeek.imageViewer.previewer.TransformItemState import com.origeek.imageViewer.previewer.rememberPreviewerState import com.origeek.imageViewer.previewer.rememberTransformItemState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.user.DynamicItem import dev.aaa1115910.biliapi.entity.user.DynamicType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.component.user.UserAvatar import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.notYetImplemented import dev.aaa1115910.bv.util.resizedImageUrl import kotlinx.coroutines.launch import java.util.UUID @Composable fun DynamicItem( modifier: Modifier = Modifier, dynamicItem: DynamicItem, previewerState: ImagePreviewerState = rememberPreviewerState(pageCount = { 0 }), onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit = { _, _ -> }, onClick: (DynamicItem) -> Unit = {} ) { val paddingSize = 12.dp Surface( modifier = modifier, onClick = { onClick(dynamicItem) }, color = MaterialTheme.colorScheme.surfaceContainerLow, ) { Column( modifier = Modifier.padding(vertical = paddingSize), verticalArrangement = Arrangement.spacedBy(8.dp), ) { DynamicHeader( modifier = Modifier.padding(horizontal = paddingSize), author = dynamicItem.author ) DynamicContent( dynamicItem = dynamicItem, horizontalPadding = paddingSize, previewerState = previewerState, onShowPreviewer = onShowPreviewer, onClick = onClick ) DynamicFooter( modifier = Modifier.padding(horizontal = paddingSize), footer = dynamicItem.footer!!, isLike = false, onShare = { //TODO 动态分享按钮 notYetImplemented() }, onShowComment = { //TODO 动态查看评论按钮 notYetImplemented() }, onLike = { //TODO 动态点赞按钮 notYetImplemented() } ) } } } @Composable fun DynamicContent( modifier: Modifier = Modifier, dynamicItem: DynamicItem, horizontalPadding: Dp = 12.dp, previewerState: ImagePreviewerState = rememberPreviewerState(pageCount = { 0 }), onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit = { _, _ -> }, onClick: (DynamicItem) -> Unit ) { val contentModifier = modifier.padding(horizontal = horizontalPadding) when (dynamicItem.type) { DynamicType.Av -> DynamicVideoContent( modifier = contentModifier, video = dynamicItem.video!! ) DynamicType.Draw -> DynamicDraw( modifier = contentModifier, draw = dynamicItem.draw!!, previewerState = previewerState, onShowPreviewer = onShowPreviewer ) DynamicType.Forward -> DynamicForward( modifier = modifier, word = dynamicItem.word, dynamicItem = dynamicItem.orig!!, previewerState = previewerState, onShowPreviewer = onShowPreviewer, onClick = { onClick(dynamicItem.orig!!) } ) DynamicType.LiveRcmd -> DynamicLiveRcmd( modifier = contentModifier, liveRcmd = dynamicItem.liveRcmd!! ) DynamicType.UgcSeason -> { Text("${dynamicItem}") } DynamicType.Word -> DynamicWord( modifier = contentModifier, word = dynamicItem.word!! ) DynamicType.Pgc -> DynamicPgc( modifier = contentModifier, pgc = dynamicItem.pgc!! ) DynamicType.Article -> DynamicArticle( modifier = contentModifier, article = dynamicItem.article!! ) DynamicType.None -> DynamicNone( modifier = contentModifier, none = dynamicItem.none!! ) } } @Composable fun DynamicVideoContent( modifier: Modifier = Modifier, video: DynamicItem.DynamicVideoModule ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (video.text.isNotBlank()) { Text(text = video.text) } Card( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f) ) { Box( contentAlignment = Alignment.BottomCenter ) { AsyncImage( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f) .clip(MaterialTheme.shapes.large), model = video.cover.resizedImageUrl(ImageSize.SmallVideoCardCover), contentDescription = null, contentScale = ContentScale.FillBounds ) Box( modifier = Modifier .fillMaxWidth() .height(48.dp) .background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.3f) ) ) ) ) Row( modifier = Modifier .fillMaxWidth() .padding(12.dp, 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (video.play.isNotBlank()) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = Color.White ) Text( text = video.play, style = MaterialTheme.typography.bodySmall, color = Color.White ) } } if (video.danmaku.isNotBlank()) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, tint = Color.White ) Text( text = video.danmaku, style = MaterialTheme.typography.bodySmall, color = Color.White ) } } } Text( text = video.duration, style = MaterialTheme.typography.bodySmall, color = Color.White ) } } } Text(text = video.title) } } @Composable fun DynamicHeader( modifier: Modifier = Modifier, author: DynamicItem.DynamicAuthorModule ) { Box( modifier = modifier .height(48.dp) .fillMaxWidth() ) { Row( modifier = Modifier .align(Alignment.CenterStart) .fillMaxWidth() .padding(end = 30.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { UserAvatar( avatar = author.avatar, size = 48.dp ) Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.SpaceAround ) { Text( text = author.author, maxLines = 1 ) Text( text = author.pubTime + " ${author.pubAction}", maxLines = 1, color = MaterialTheme.colorScheme.onSurface.copy(0.8f), fontSize = 14.sp, lineHeight = 14.sp ) } } IconButton( modifier = Modifier .align(Alignment.CenterEnd) .size(30.dp), onClick = { //TODO 动态右上角按钮 notYetImplemented() } ) { Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Menu") } } } @Composable fun DynamicForwardHeader( modifier: Modifier = Modifier, author: DynamicItem.DynamicAuthorModule ) { Box( modifier = modifier .height(24.dp) .fillMaxWidth() ) { Row( modifier = Modifier .align(Alignment.CenterStart) .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { UserAvatar( avatar = author.avatar, size = 20.dp ) Text( text = author.author, maxLines = 1, fontSize = 14.sp, lineHeight = 14.sp ) Text( text = author.pubTime + " ${author.pubAction}", maxLines = 1, color = MaterialTheme.colorScheme.onSurface.copy(0.8f), fontSize = 14.sp, lineHeight = 14.sp ) } } } @Composable fun DynamicFooter( modifier: Modifier = Modifier, footer: DynamicItem.DynamicFooterModule, isLike: Boolean = false, onShare: (() -> Unit)? = null, onShowComment: (() -> Unit)? = null, onLike: (() -> Unit)? = null ) { Row( modifier = modifier .fillMaxWidth() .height(40.dp), horizontalArrangement = Arrangement.SpaceAround, ) { DynamicFooterButton( icon = Icons.Default.Share, number = footer.share ) { onShare?.invoke() } DynamicFooterButton( icon = Icons.AutoMirrored.Filled.Comment, number = footer.comment ) { onShowComment?.invoke() } DynamicFooterButton( icon = if (isLike) Icons.Default.Favorite else Icons.Default.FavoriteBorder, number = footer.like ) { onLike?.invoke() } } } @Composable fun DynamicFooterButton( modifier: Modifier = Modifier, icon: ImageVector, number: Int, onClick: () -> Unit ) { TextButton( modifier = modifier, onClick = onClick ) { Icon( modifier = Modifier.size(16.dp), imageVector = icon, contentDescription = null ) Text( text = number.toString(), modifier = Modifier.padding(start = 4.dp) ) } } //TODO 富文本 @Composable fun DynamicDraw( modifier: Modifier = Modifier, draw: DynamicItem.DynamicDrawModule, previewerState: ImagePreviewerState, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (draw.title != null) { Text( text = draw.title!!, fontWeight = FontWeight.Bold ) } Text(text = draw.text) DynamicPictures( pictures = draw.images, previewerState = previewerState, onShowPreviewer = onShowPreviewer ) } } @Composable fun DynamicPictures( modifier: Modifier = Modifier, pictures: List, previewerState: ImagePreviewerState, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, ) { val scope = rememberCoroutineScope() val imageBaseShape = MaterialTheme.shapes.medium val onClickPicture: (index: Int, itemState: TransformItemState) -> Unit = { index, itemState -> onShowPreviewer(pictures) { scope.launch { previewerState.openTransform( index = index, itemState = itemState, ) } } } Box( modifier = modifier ) { when { pictures.size == 1 -> { Row { val itemState = rememberTransformItemState() Card( modifier = Modifier .weight(1f) .aspectRatio(2f), shape = imageBaseShape, onClick = { onClickPicture(0, itemState) } ) { TransformImageView( painter = rememberAsyncImagePainter(pictures.first().url), key = pictures.first().key, itemState = itemState, previewerState = previewerState, ) } } } pictures.size == 2 -> { Row( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { pictures.forEachIndexed { index, picture -> val itemState = rememberTransformItemState() Card( modifier = Modifier .weight(1f) .aspectRatio(1f), shape = when (index) { 0 -> imageBaseShape.copy( topEnd = CornerSize(0.dp), bottomEnd = CornerSize(0.dp) ) 1 -> imageBaseShape.copy( topStart = CornerSize(0.dp), bottomStart = CornerSize(0.dp) ) else -> RoundedCornerShape(0.dp) }, onClick = { onClickPicture(index, itemState) } ) { TransformImageView( painter = rememberAsyncImagePainter(picture.url), key = picture.key, itemState = itemState, previewerState = previewerState, ) } } } } pictures.size >= 3 -> { Row( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { pictures.take(3).forEachIndexed { index, picture -> val itemState = rememberTransformItemState() Card( modifier = Modifier .weight(1f) .aspectRatio(1f), shape = when (index) { 0 -> imageBaseShape.copy( topEnd = CornerSize(0.dp), bottomEnd = CornerSize(0.dp) ) 2 -> imageBaseShape.copy( topStart = CornerSize(0.dp), bottomStart = CornerSize(0.dp) ) else -> RoundedCornerShape(0.dp) }, onClick = { onClickPicture(index, itemState) } ) { TransformImageView( painter = rememberAsyncImagePainter(picture.url), key = picture.key, itemState = itemState, previewerState = previewerState, ) } } } if (pictures.size > 3) { Text( modifier = Modifier .align(Alignment.BottomEnd) .clip( MaterialTheme.shapes.medium.copy( topEnd = CornerSize(0.dp), bottomStart = CornerSize(0.dp) ) ) .background(Color.Black.copy(alpha = 0.2f)) .padding(horizontal = 8.dp), text = "+${pictures.size - 3}", color = Color.White ) } } } } } @Composable fun DynamicWord( modifier: Modifier = Modifier, word: DynamicItem.DynamicWordModule ) { Text( modifier = modifier, text = word.text ) } @Composable fun DynamicForward( modifier: Modifier = Modifier, word: DynamicItem.DynamicWordModule?, dynamicItem: DynamicItem, previewerState: ImagePreviewerState, horizontalPadding: Dp = 12.dp, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, onClick: () -> Unit ) { Column( modifier = modifier, ) { if (word != null) { Text( modifier = Modifier.padding(horizontal = horizontalPadding), text = word.text ) } Surface( color = MaterialTheme.colorScheme.surfaceContainer, onClick = onClick ) { Box( modifier = Modifier.padding(horizontal = horizontalPadding, vertical = 6.dp), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (dynamicItem.author.mid != -1L) { DynamicForwardHeader( author = dynamicItem.author ) } DynamicContent( modifier = Modifier.fillMaxWidth(), dynamicItem = dynamicItem, horizontalPadding = 0.dp, previewerState = previewerState, onShowPreviewer = onShowPreviewer, onClick = {} ) } } } } } @Composable fun DynamicLiveRcmd( modifier: Modifier = Modifier, liveRcmd: DynamicItem.DynamicLiveRcmdModule ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Card( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f) ) { Box( contentAlignment = Alignment.BottomCenter ) { AsyncImage( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f) .clip(MaterialTheme.shapes.large), model = liveRcmd.cover.resizedImageUrl(ImageSize.SmallVideoCardCover), contentDescription = null, contentScale = ContentScale.FillBounds ) Box( modifier = Modifier .fillMaxWidth() .height(48.dp) .background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.3f) ) ) ) ) Row( modifier = Modifier .fillMaxWidth() .padding(12.dp, 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = "${liveRcmd.roomId}", style = MaterialTheme.typography.bodySmall, color = Color.White ) } } } Text(text = liveRcmd.title) } } @Composable fun DynamicPgc( modifier: Modifier = Modifier, pgc: DynamicItem.DynamicPgcModule ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Card( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f) ) { AsyncImage( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f) .clip(MaterialTheme.shapes.large), model = pgc.cover.resizedImageUrl(ImageSize.SmallVideoCardCover), contentDescription = null, contentScale = ContentScale.FillBounds ) } Text(text = pgc.title) } } @Composable fun DynamicArticle( modifier: Modifier = Modifier, article: DynamicItem.DynamicArticleModule ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = article.title, fontWeight = FontWeight.Bold ) Text(text = article.text) if (article.covers.isNotEmpty()) { AsyncImage( modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.large), model = article.covers.first().resizedImageUrl(ImageSize.SmallVideoCardCover), contentDescription = null, contentScale = ContentScale.FillBounds ) } } } @Composable fun DynamicNone( modifier: Modifier = Modifier, none: DynamicItem.DynamicNoneModule ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text(text = none.text) } } @Preview @Composable private fun DynamicHeaderPreview() { BVMobileTheme { Surface { DynamicHeader( author = emptyDynamicVideoData.author ) } } } @Preview @Composable private fun DynamicForwardHeaderPreview() { BVMobileTheme { Surface { DynamicForwardHeader( author = emptyDynamicVideoData.author ) } } } @Preview @Composable private fun DynamicFooterPreview() { BVMobileTheme { Surface { DynamicFooter( footer = exampleFooterData ) } } } private val exampleAuthorData = DynamicItem.DynamicAuthorModule( author = "author", avatar = "", mid = 0, pubTime = "54 分钟前 投稿了视频", pubAction = "" ) private val exampleFooterData = DynamicItem.DynamicFooterModule( like = 2, comment = 61, share = 8, ) private val exampleVideoData = DynamicItem.DynamicVideoModule( aid = 0, title = "title", cover = "", duration = "23:45", play = "xx play", danmaku = "xx dm", seasonId = 0, cid = 0, text = "desc" ) private val emptyDynamicData = DynamicItem( type = DynamicType.Av, author = exampleAuthorData, footer = exampleFooterData ) private val emptyDynamicVideoData = DynamicItem( type = DynamicType.Av, author = exampleAuthorData, video = exampleVideoData, footer = exampleFooterData ) private val emptyDynamicDrawData = DynamicItem( type = DynamicType.Draw, author = exampleAuthorData, draw = DynamicItem.DynamicDrawModule( title = "title", text = "draw", images = emptyList() ), footer = exampleFooterData ) private val emptyDynamicWordData = DynamicItem.DynamicWordModule( text = "this is word module" ) private val exampleDynamicForwardData = DynamicItem( type = DynamicType.Forward, author = exampleAuthorData, orig = emptyDynamicVideoData, word = emptyDynamicWordData, footer = exampleFooterData ) private val exampleDynamicForwardNoneData = DynamicItem( type = DynamicType.Forward, author = exampleAuthorData, orig = DynamicItem( type = DynamicType.None, author = DynamicItem.DynamicAuthorModule("", "", -1, "", ""), none = DynamicItem.DynamicNoneModule("unknown dynamic") ), word = emptyDynamicWordData, footer = exampleFooterData ) private val exampleDynamicLiveRcmdData = DynamicItem( type = DynamicType.LiveRcmd, author = exampleAuthorData, liveRcmd = DynamicItem.DynamicLiveRcmdModule( cover = "", title = "title", roomId = 3 ), footer = exampleFooterData ) private val exampleDynamicPgcData = DynamicItem( type = DynamicType.Pgc, author = exampleAuthorData, pgc = DynamicItem.DynamicPgcModule( cover = "", title = "title", seasonId = 3, epid = 3, aid = 0, cid = 0 ), footer = exampleFooterData ) private val exampleDynamicArticleData = DynamicItem( type = DynamicType.Article, author = exampleAuthorData, article = DynamicItem.DynamicArticleModule( title = "title", text = "article content", covers = listOf(""), id = 0, url = "", label = "" ), footer = exampleFooterData ) @Preview @Composable private fun DynamicVideoItemPreview() { BVMobileTheme { Surface { DynamicItem( modifier = Modifier.padding(vertical = 8.dp), dynamicItem = emptyDynamicVideoData ) } } } private class DynamicDrawItemProvider : PreviewParameterProvider { override val values = List(5) { index -> emptyDynamicData.copy( type = DynamicType.Draw, draw = DynamicItem.DynamicDrawModule( title = "title", text = "this is $index picture draw", images = Array(index) { Picture("", 0, 0, "${UUID.randomUUID()}") }.toList() ) ) }.asSequence() } @Preview @Composable private fun DynamicDrawItemPreview(@PreviewParameter(DynamicDrawItemProvider::class) dynamicItem: DynamicItem) { BVMobileTheme { Surface { DynamicItem( modifier = Modifier.padding(vertical = 8.dp), dynamicItem = dynamicItem ) } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DynamicForwardItemPreview() { BVMobileTheme { Surface { DynamicItem( modifier = Modifier.padding(vertical = 8.dp), dynamicItem = exampleDynamicForwardData ) } } } @Preview @Composable private fun DynamicForwardItemNonePreview() { BVMobileTheme { Surface { DynamicItem( modifier = Modifier.padding(vertical = 8.dp), dynamicItem = exampleDynamicForwardNoneData ) } } } @Preview @Composable private fun DynamicLiveRcmdItemPreview() { BVMobileTheme { Surface { DynamicItem( modifier = Modifier.padding(vertical = 8.dp), dynamicItem = exampleDynamicLiveRcmdData ) } } } @Preview @Composable private fun DynamicItemListPreview() { BVMobileTheme { Surface { LazyColumn( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant) ) { items(3) { DynamicItem( modifier = Modifier.padding(bottom = 8.dp), dynamicItem = emptyDynamicVideoData ) } } } } } @Preview @Composable private fun DynamicPgcItemPreview() { BVMobileTheme { Surface { DynamicItem( modifier = Modifier.padding(vertical = 8.dp), dynamicItem = exampleDynamicPgcData ) } } } @Preview @Composable private fun DynamicArticleItemPreview() { BVMobileTheme { Surface { DynamicItem( modifier = Modifier.padding(vertical = 8.dp), dynamicItem = exampleDynamicArticleData ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/home/dynamic/DynamicUserItem.kt ================================================ package dev.aaa1115910.bv.mobile.component.home.dynamic import android.content.res.Configuration import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.mobile.component.user.UserAvatar import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable fun DynamicUserItem( modifier: Modifier = Modifier, avatar: String, username: String ) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally ) { UserAvatar(avatar = avatar) Text( modifier = Modifier.width(80.dp), text = username, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DynamicUserItemPreview() { BVMobileTheme { Surface { DynamicUserItem( avatar = "https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg", username = "bishi" ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/player/VideoPlayerPages.kt ================================================ package dev.aaa1115910.bv.mobile.component.player import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Done import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import com.airbnb.lottie.LottieProperty import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieDynamicProperties import com.airbnb.lottie.compose.rememberLottieDynamicProperty import dev.aaa1115910.biliapi.entity.video.Dimension import dev.aaa1115910.biliapi.entity.video.VideoPage import dev.aaa1115910.biliapi.entity.video.season.Episode import dev.aaa1115910.biliapi.entity.video.season.Section import dev.aaa1115910.biliapi.entity.video.season.UgcSeason import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.formatHourMinSec @OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoPlayerPages( modifier: Modifier = Modifier, currentCid: Long, pages: List, ugcSeason: UgcSeason?, pgcSections: List
, onClickPage: (VideoPage) -> Unit, onClickEpisode: (sectionIndex: Int, episode: Episode) -> Unit ) { val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, confirmValueChange = { sheetValue -> println("confirmValueChange: $sheetValue") true } ) var openBottomSheet by rememberSaveable { mutableStateOf(false) } var currentSection by remember { mutableStateOf(null) } LaunchedEffect(currentCid) { if (pgcSections.isNotEmpty()) { currentSection = pgcSections.find { it.episodes.any { episode -> episode.cid == currentCid } } } else if (ugcSeason != null) { currentSection = ugcSeason.sections.find { it.episodes.any { episode -> episode.cid == currentCid || episode.pages.any { page -> page.cid == currentCid } } } } } Column( modifier = modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainer) ) { if (pgcSections.isNotEmpty()) { // TODO pgc } else if (ugcSeason != null) { // TODO ugc if (currentSection != null) { //VideoPlayerUgcSectionsFilter( // sections = ugcSeason.sections, // currentSection = currentSection!!, // onSectionChange = { currentSection = it } //) VideoPlayerEpisodesRow( //title = currentSection!!.title, episodes = currentSection!!.episodes, onClickMore = { openBottomSheet = !openBottomSheet }, onClickEpisode = { episode -> onClickEpisode( ugcSeason.sections.indexOf(currentSection), episode ) }, currentCid = currentCid ) } } else if (pages.size > 1) { VideoPlayerPagesRow( //title = "视频分 P", pages = pages, onClickMore = { openBottomSheet = !openBottomSheet }, onClickPage = onClickPage, currentCid = currentCid ) } } if (openBottomSheet) { ModalBottomSheet( sheetState = sheetState, onDismissRequest = { openBottomSheet = false }, contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { VideoPlayerPartSheetContent( currentCid = currentCid, pages = pages, ugcSeason = ugcSeason, pgcSections = pgcSections, onClickPage = onClickPage, onClickEpisode = { episode -> onClickEpisode( ugcSeason!!.sections.indexOf(currentSection), episode ) } ) } } } @Composable private fun VideoPlayerUgcSectionsFilter( modifier: Modifier = Modifier, sections: List
, currentSection: Section, onSectionChange: (Section) -> Unit = {} ) { LazyRow { items(sections) { section -> VideoPlayerUgcSectionsFilterChip( modifier = modifier, section = section, selected = section == currentSection, onClick = { onSectionChange(section) } ) } } } @Composable fun VideoPlayerUgcSectionsFilterChip( modifier: Modifier = Modifier, section: Section, selected: Boolean, onClick: () -> Unit ) { FilterChip( modifier = modifier, onClick = onClick, label = { Text( text = section.title, style = MaterialTheme.typography.titleSmall ) }, selected = selected, leadingIcon = (@Composable { Icon( imageVector = Icons.Filled.Done, contentDescription = null, modifier = Modifier.size(FilterChipDefaults.IconSize) ) }).takeIf { selected } ) } @Composable fun VideoPlayerEpisodesRow( modifier: Modifier = Modifier, title: String? = null, episodes: List, currentCid: Long, onClickMore: () -> Unit = {}, onClickEpisode: (Episode) -> Unit = {} ) { Column( modifier = modifier .background(MaterialTheme.colorScheme.surface) ) { if (title != null) { Text( modifier = Modifier.padding(horizontal = 8.dp), text = title, style = MaterialTheme.typography.titleSmall ) } Box { LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues( start = 8.dp, end = 68.dp, top = 8.dp, bottom = 8.dp ), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed(episodes) { index, episode -> VideoPlayerPageItem( modifier = modifier, text = "EP${index + 1} ${episode.title}", onClick = { onClickEpisode(episode) }, isPlaying = episode.cid == currentCid ) } } MoreButton( modifier = Modifier .align(Alignment.CenterEnd), onClick = onClickMore ) } } } @Composable fun VideoPlayerPagesRow( modifier: Modifier = Modifier, title: String? = null, pages: List, currentCid: Long, onClickMore: () -> Unit = {}, onClickPage: (VideoPage) -> Unit = {} ) { Column( modifier = modifier .background(MaterialTheme.colorScheme.surface) ) { if (title != null) { Text( modifier = Modifier.padding(horizontal = 8.dp), text = title, style = MaterialTheme.typography.titleSmall ) } Box( modifier = Modifier.fillMaxWidth() ) { LazyRow( contentPadding = PaddingValues( start = 8.dp, end = 68.dp, top = 8.dp, bottom = 8.dp ), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed(pages) { index, page -> VideoPlayerPageItem( modifier = modifier, text = "P${index + 1} ${page.title}", onClick = { onClickPage(page) }, isPlaying = page.cid == currentCid ) } } MoreButton( modifier = Modifier .align(Alignment.CenterEnd), onClick = onClickMore ) } } } @Composable private fun MoreButton( modifier: Modifier = Modifier, onClick: () -> Unit = {} ) { var color by remember { mutableStateOf(Color.Red) } color = MaterialTheme.colorScheme.surface val colorStops = arrayOf( 0.0f to Color.Transparent, 0.4f to MaterialTheme.colorScheme.surface, 1f to MaterialTheme.colorScheme.surface ) Box( modifier = modifier .width(60.dp) .height(80.dp) .background(Brush.horizontalGradient(colorStops = colorStops)), contentAlignment = Alignment.Center ) { IconButton( modifier = Modifier .align(Alignment.Center) .offset(x = 8.dp), onClick = onClick ) { Icon(imageVector = Icons.Default.ChevronRight, contentDescription = null) } } } @Composable private fun VideoPlayerPageItem( modifier: Modifier = Modifier, text: String, isPlaying: Boolean, onClick: () -> Unit ) { val density = LocalDensity.current val inlineContentMap = mapOf( "playingIcon" to InlineTextContent( Placeholder( width = with(density) { 20.dp.toSp() }, height = with(density) { 20.dp.toSp() }, placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter ) ) { PlayingIcon() } ) val annotatedString = buildAnnotatedString { if (isPlaying) appendInlineContent("playingIcon") append(text) } Box( modifier = modifier .width(160.dp) .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceContainer) .clickable { onClick() } ) { Text( modifier = Modifier.padding(8.dp), text = annotatedString, maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis, inlineContent = inlineContentMap, color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun VideoPlayerPartSheetContent( modifier: Modifier = Modifier, currentCid: Long, pages: List, ugcSeason: UgcSeason?, pgcSections: List
, onClickPage: (VideoPage) -> Unit, onClickEpisode: (Episode) -> Unit ) { var currentSection by remember { mutableStateOf(ugcSeason?.sections?.first()) } val onClickSectionTab: (Section) -> Unit = { section -> currentSection = section } LaunchedEffect(currentCid) { if (pgcSections.isNotEmpty()) { currentSection = pgcSections.find { it.episodes.any { episode -> episode.cid == currentCid } } } else if (ugcSeason != null) { currentSection = ugcSeason.sections.find { it.episodes.any { episode -> episode.cid == currentCid || episode.pages.any { page -> page.cid == currentCid } } } } } Column( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) ) { Row { TopAppBar( title = { Text( text = if (pgcSections.isNotEmpty()) { "视频选集" } else if (ugcSeason != null) { "视频选集" } else { "视频分 P" }, ) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ), windowInsets = WindowInsets(0, 0, 0, 0) ) } //Text("ugcSeason: $ugcSeason") if (pgcSections.isNotEmpty()) { // TODO pgc Text("pgc") } else if (ugcSeason != null) { // TODO ugc if (currentSection != null) { if (ugcSeason.sections.size > 1) { SecondaryScrollableTabRow( selectedTabIndex = ugcSeason.sections.indexOf(currentSection!!), containerColor = MaterialTheme.colorScheme.surfaceContainerLow, divider = {} ) { ugcSeason.sections.forEach { section -> Tab( selected = currentSection == section, onClick = { onClickSectionTab(section) } ) { Box( modifier = Modifier.height(48.dp), contentAlignment = Alignment.Center ) { Text( modifier = Modifier.padding(horizontal = 16.dp), text = section.title, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) } } } } } HorizontalDivider() LazyColumn( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp) ) { itemsIndexed(currentSection!!.episodes) { epIndex, episode -> if (episode.pages.size <= 1) { PageListItem( modifier = modifier, text = "EP${epIndex + 1} ${episode.title}", duration = episode.duration, isPlaying = episode.cid == currentCid, onClick = { onClickEpisode(episode) } ) } else { Column { var expand by remember { mutableStateOf(true) } LaunchedEffect(currentSection) { expand = true } PageListItem( modifier = modifier, text = "EP${epIndex + 1} ${episode.title}", duration = null, isPlaying = episode.pages.any { it.cid == currentCid }, onClick = { expand = !expand } ) AnimatedVisibility( visible = expand ) { Column( modifier = Modifier .padding(start = 16.dp) ) { episode.pages.forEachIndexed { pageIndex, page -> PageListItem( modifier = modifier, text = "P${pageIndex + 1} ${page.title}", duration = page.duration, isPlaying = page.cid == currentCid, onClick = { onClickPage(page) } ) } } } } } } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } } else if (pages.size > 1) { HorizontalDivider() LazyColumn { itemsIndexed(pages) { index, page -> PageListItem( modifier = modifier, text = "P${index + 1} ${page.title}", duration = page.duration, isPlaying = page.cid == currentCid, onClick = { onClickPage(page) } ) } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } } } @Composable private fun PageListItem( modifier: Modifier = Modifier, text: String, duration: Int?, isPlaying: Boolean, onClick: () -> Unit = {} ) { val density = LocalDensity.current val inlineContentMap = mapOf( "playingIcon" to InlineTextContent( Placeholder( width = with(density) { 20.dp.toSp() }, height = with(density) { 20.dp.toSp() }, placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter ) ) { PlayingIcon() } ) val annotatedString = buildAnnotatedString { if (isPlaying) appendInlineContent("playingIcon") append(text) } ListItem( modifier = modifier .height(40.dp) .clip(MaterialTheme.shapes.medium) .clickable { onClick() }, headlineContent = { Text( text = annotatedString, maxLines = 1, overflow = TextOverflow.Ellipsis, inlineContent = inlineContentMap, ) }, trailingContent = (@Composable { Text( text = (1000 * (duration?.toLong() ?: 0)).formatHourMinSec(), style = MaterialTheme.typography.bodySmall.copy( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) ) }).takeIf { duration != null }, colors = ListItemDefaults.colors( headlineColor = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified, containerColor = Color.Transparent ), ) } @Composable private fun PlayingIcon(modifier: Modifier = Modifier) { val dynamicProperties = rememberLottieDynamicProperties( rememberLottieDynamicProperty( property = LottieProperty.COLOR_FILTER, value = BlendModeColorFilterCompat.createBlendModeColorFilterCompat( MaterialTheme.colorScheme.primary.hashCode(), BlendModeCompat.SRC_ATOP ), keyPath = arrayOf( "**" ) ) ) val composition by rememberLottieComposition( LottieCompositionSpec.RawRes(R.raw.ic_playing) ) val progress by animateLottieCompositionAsState( composition, iterations = LottieConstants.IterateForever ) LottieAnimation( modifier = Modifier .size(20.dp) .scale(2f), composition = composition, progress = { progress }, dynamicProperties = dynamicProperties, clipTextToBoundingBox = true ) } @Preview @Composable private fun VideoPlayerPageWithoutTitlePreview() { BVMobileTheme { VideoPlayerPagesRow( pages = List(10) { VideoPage( cid = it.toLong(), index = it, title = "Page title $it", duration = 1, dimension = Dimension(0, 0) ) }, currentCid = 0 ) } } @Preview @Composable private fun VideoPlayerPageWithTitlePreview() { BVMobileTheme { VideoPlayerPagesRow( title = "Title", pages = List(10) { VideoPage(it.toLong(), it, "Title", 1, Dimension(0, 0)) }, currentCid = 0 ) } } @Preview @Composable private fun PlayingIconPreview() { PlayingIcon() } @Preview @Composable private fun PageListPreview() { BVMobileTheme { Surface { Column { repeat(10) { PageListItem( isPlaying = it == 0, duration = 233, text = "This is a page list item title" ) } } } } } @Preview @Composable private fun VideoPlayerPartSheetContentPagesPreview() { val pages = List(10) { VideoPage( cid = it.toLong(), index = it, title = "Page title $it", duration = 1, dimension = Dimension(0, 0) ) } BVMobileTheme { VideoPlayerPartSheetContent( currentCid = 1, pages = pages, ugcSeason = null, pgcSections = emptyList(), onClickPage = {}, onClickEpisode = {} ) } } @Preview @Composable private fun VideoPlayerPartSheetContentUgcSeasonPreview() { val ugcSeason by remember { mutableStateOf(UgcSeason( id = 0, title = "Ugc Season Title", cover = "", sections = List(3) { sectionIndex -> Section( id = sectionIndex.toLong(), title = "Section $sectionIndex", episodes = List(10) { episodeIndex -> Episode( id = episodeIndex, cid = episodeIndex.toLong(), title = "Section $sectionIndex Episode $episodeIndex", aid = episodeIndex.toLong(), bvid = "", longTitle = "Episode long title $episodeIndex", cover = "", duration = 111, dimension = Dimension(0, 0), pages = if (episodeIndex == 3) { List(10) { pageIndex -> VideoPage( cid = 100 + pageIndex.toLong(), index = pageIndex, title = "Pages in sections $pageIndex", duration = 100, dimension = Dimension(0, 0) ) } } else { emptyList() } ) } ) } )) } BVMobileTheme { VideoPlayerPartSheetContent( currentCid = 102, pages = emptyList(), ugcSeason = ugcSeason, pgcSections = emptyList(), onClickPage = {}, onClickEpisode = {} ) } } @Preview @Composable private fun VideoPlayerPartSheetContentPgcSectionsPreview() { val pages = List(10) { VideoPage( cid = it.toLong(), index = it, title = "Page title $it", duration = 1, dimension = Dimension(0, 0) ) } BVMobileTheme { VideoPlayerPartSheetContent( currentCid = 1, pages = pages, ugcSeason = null, pgcSections = emptyList(), onClickPage = {}, onClickEpisode = {} ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/preferences/PreferenceGroup.kt ================================================ package dev.aaa1115910.bv.mobile.component.preferences import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp fun LazyListScope.preferenceGroups( vararg groupContents: Pair Unit>, groupSpacing: Dp = 12.dp ) { var isFirstGroup = true groupContents.forEachIndexed { index, (title, content) -> val scope = PreferenceGroupScope(title, index) scope.content() if (scope.preferences.isNotEmpty()) { if (!isFirstGroup) { item( key = "preference_group_spacing_$index" ) { Spacer(modifier = Modifier.height(groupSpacing)) } } scope.build(this) isFirstGroup = false } } } fun LazyListScope.preferenceGroup( title: String? = null, index: Int = 0, content: PreferenceGroupScope.() -> Unit ) { val scope = PreferenceGroupScope(title, index) scope.content() if (scope.preferences.isEmpty()) return scope.build(this) } @DslMarker annotation class PreferenceGroupScopeMarker @PreferenceGroupScopeMarker class PreferenceGroupScope internal constructor( private val title: String? = null, private val index: Int ) { val preferences = mutableListOf<@Composable (shape: Shape, modifier: Modifier) -> Unit>() companion object { private val LARGE_CORNER_RADIUS = 16.dp private val SMALL_CORNER_RADIUS = 4.dp } internal fun build(listScope: LazyListScope) { if (title != null) { listScope.item( key = "preference_group_title_${this.index}_${title}" ) { Text( text = title, style = MaterialTheme.typography.labelMedium, modifier = Modifier .padding(vertical = 8.dp, horizontal = 4.dp) .animateItem() ) } } preferences.forEachIndexed { index, itemContent -> val isFirst = index == 0 val isLast = index == preferences.lastIndex val shape = when { isFirst && isLast -> RoundedCornerShape(LARGE_CORNER_RADIUS) isFirst -> RoundedCornerShape( topStart = LARGE_CORNER_RADIUS, topEnd = LARGE_CORNER_RADIUS, bottomStart = SMALL_CORNER_RADIUS, bottomEnd = SMALL_CORNER_RADIUS ) isLast -> RoundedCornerShape( bottomStart = LARGE_CORNER_RADIUS, bottomEnd = LARGE_CORNER_RADIUS, topStart = SMALL_CORNER_RADIUS, topEnd = SMALL_CORNER_RADIUS ) else -> RoundedCornerShape(SMALL_CORNER_RADIUS) } val modifier = if (!isFirst) Modifier.padding(top = 2.dp) else Modifier listScope.item( key = "preference_group_${this.index}_${title}_${index}" ) { itemContent(shape, modifier.animateItem()) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/preferences/PreferencesPreview.kt ================================================ package dev.aaa1115910.bv.mobile.component.preferences import android.content.res.Configuration import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.mobile.component.preferences.items.switchPreference import dev.aaa1115910.bv.mobile.component.preferences.items.textPreference import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreferencesPreview() { BVMobileTheme { var showHiddenPreference by remember { mutableStateOf(false) } Surface( color = MaterialTheme.colorScheme.surfaceContainerLow ) { LazyColumn( modifier = Modifier.padding(12.dp), ) { item { Text( modifier = Modifier.animateItem(), text = "Preferences Preview", style = MaterialTheme.typography.headlineSmall ) } preferenceGroups( "Group 1" to { textPreference( title = "Text Preference", summary = "This is a summary", ) textPreference( title = "Selected Text Preference", summary = "This is another summary", selected = true ) textPreference( title = "No Summary", ) textPreference( title = "Clickable Text Preference", summary = "This preference is clickable", onClick = { /* Handle click */ } ) textPreference( title = "Disabled Text Preference", summary = "This preference is disabled", enabled = false ) textPreference( title = "Icon Text Preference", summary = "This preference has an icon", icon = Icons.Default.PlayCircleOutline, ) }, "Group 2" to { textPreference( title = "Text Preference", summary = "This is a summary", ) switchPreference( title = "Switch Preference", summary = "This is a summary", leadingContent = { Icon( imageVector = Icons.Default.PlayCircleOutline, contentDescription = null ) }, onClick = { showHiddenPreference = !showHiddenPreference }, checked = showHiddenPreference, onCheckedChange = { showHiddenPreference = !showHiddenPreference } ) if (showHiddenPreference) { textPreference(title = "Hidden Preference") } }, null to { textPreference( title = "Text Preference", summary = "This is a summary", ) } ) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/preferences/items/BaseListItem.kt ================================================ package dev.aaa1115910.bv.mobile.component.preferences.items import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable fun BaseListItem( headlineContent: @Composable () -> Unit, modifier: Modifier = Modifier, overlineContent: @Composable (() -> Unit)? = null, supportingContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, colors: ListItemColors = ListItemDefaults.colors().copy( containerColor = MaterialTheme.colorScheme.surfaceBright, supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), ), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, selected: Boolean = false, enabled: Boolean = true, shape: Shape = MaterialTheme.shapes.medium, onClick: (() -> Unit)? = null, ) { ListItem( modifier = modifier .clip(shape) .heightIn(min = 72.dp) .clickable( enabled = enabled, onClick = { onClick?.invoke() } ), headlineContent = headlineContent, overlineContent = overlineContent, supportingContent = supportingContent, leadingContent = leadingContent, trailingContent = trailingContent, colors = if (!enabled) colors.copy( //containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.38f), headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), leadingIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), overlineColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), ) else if (selected) colors.copy( containerColor = MaterialTheme.colorScheme.secondaryContainer ) else colors, tonalElevation = tonalElevation, shadowElevation = shadowElevation ) } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/preferences/items/ListItemPreference.kt ================================================ package dev.aaa1115910.bv.mobile.component.preferences.items import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.mobile.component.preferences.PreferenceGroupScope import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable private fun ListItemPreference( modifier: Modifier = Modifier, headlineContent: @Composable () -> Unit, overlineContent: @Composable (() -> Unit)? = null, supportingContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, colors: ListItemColors = ListItemDefaults.colors(), shape: Shape = RoundedCornerShape(0.dp), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, onClick: (() -> Unit)? = null ) { ListItem( modifier = modifier .clip(shape) .clickable { onClick?.invoke() }, headlineContent = headlineContent, overlineContent = overlineContent, supportingContent = supportingContent, leadingContent = leadingContent, trailingContent = trailingContent, colors = colors, tonalElevation = tonalElevation, shadowElevation = shadowElevation ) } fun PreferenceGroupScope.listItemPreference( headlineContent: @Composable () -> Unit, overlineContent: @Composable (() -> Unit)? = null, supportingContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, colors: ListItemColors? = null, onClick: (() -> Unit)? = null, ) { preferences += { shape, modifier -> ListItemPreference( modifier = modifier, headlineContent = headlineContent, overlineContent = overlineContent, supportingContent = supportingContent, leadingContent = leadingContent, trailingContent = trailingContent, colors = colors ?: ListItemDefaults.colors(), shape = shape, onClick = onClick ) } } fun PreferenceGroupScope.listItemPreference( title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, ) = listItemPreference( headlineContent = @Composable { Text(text = title) }, supportingContent = if (summary != null) (@Composable { Text(text = summary) }) else null, leadingContent = if (icon != null) (@Composable { Icon( imageVector = icon, contentDescription = null ) }) else null, onClick = onClick, ) @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ListItemPreferencePreview() { BVMobileTheme { ListItemPreference( headlineContent = { Text("Text Preference") }, //supportingContent = { Text("This is a summary") }, leadingContent = { Icon( imageVector = Icons.Default.PlayCircleOutline, contentDescription = null ) }, trailingContent = { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null ) }, onClick = { /* Handle click */ }, shape = RoundedCornerShape(8.dp), ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/preferences/items/RadioPreference.kt ================================================ package dev.aaa1115910.bv.mobile.component.preferences.items import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.schnettler.datastore.manager.DataStoreManager import de.schnettler.datastore.manager.PreferenceRequest import dev.aaa1115910.bv.dataStore import dev.aaa1115910.bv.mobile.component.preferences.PreferenceGroupScope import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable private fun RadioPreference( modifier: Modifier = Modifier, title: String, summary: String?, selected: Boolean = false, shape: Shape = RoundedCornerShape(0.dp), enabled: Boolean = true, leadingContent: @Composable() (() -> Unit)? = null, value: T, values: Map, onValueChange: ((T) -> Unit) ) { var showDialog by remember { mutableStateOf(false) } BaseListItem( modifier = modifier, headlineContent = { Text(text = title) }, supportingContent = { Text(text = summary ?: "unknown") }, selected = selected, enabled = enabled, leadingContent = leadingContent, onClick = { showDialog = true }, shape = shape ) if (showDialog) { RadioDialog( modifier = Modifier.fillMaxWidth(), title = title, value = value, values = values, onValueChange = { newValue -> onValueChange(newValue) showDialog = false }, onDismissRequest = { showDialog = false } ) } } @Composable private fun RadioDialog( modifier: Modifier = Modifier, title: String, value: T, values: Map, onValueChange: (T) -> Unit, onDismissRequest: () -> Unit ) { AlertDialog( modifier = modifier, onDismissRequest = onDismissRequest, title = { Text(text = title) }, text = { LazyColumn { items(values.toList()) { (itemValue, label) -> ListItem( modifier = Modifier .clip(MaterialTheme.shapes.large) .clickable { onValueChange(itemValue) }, headlineContent = { Text(text = label) }, leadingContent = { RadioButton( selected = itemValue == value, onClick = { onValueChange(itemValue) } ) }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, ) ) } } }, confirmButton = {}, ) } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun RadioPreferencePreview() { BVMobileTheme { RadioPreference( title = "Radio Preference", summary = "value", leadingContent = { Icon( imageVector = Icons.Default.PlayCircleOutline, contentDescription = null ) }, selected = false, shape = RoundedCornerShape(8.dp), enabled = true, value = 123, values = mapOf( 123 to "Option 1", 456 to "Option 2", 789 to "Option 3" ), onValueChange = { /* Handle checked change */ } ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun RadioDialogPreview() { BVMobileTheme { var value by remember { mutableIntStateOf(123) } RadioDialog( title = "Select Option", value = value, values = mapOf( 123 to "Option 1", 456 to "Option 2", 789 to "Option 3" ), onValueChange = { value = it }, onDismissRequest = { /* Handle dismiss */ } ) } } fun PreferenceGroupScope.radioPreference( title: String, leadingContent: @Composable() (() -> Unit)? = null, onSelected: Boolean = false, enabled: Boolean = true, value: T, values: Map, onValueChange: (T) -> Unit ) { preferences += { shape, modifier -> RadioPreference( modifier = modifier, title = title, summary = values[value], leadingContent = leadingContent, selected = onSelected, shape = shape, enabled = enabled, value = value, values = values, onValueChange = onValueChange ) } } fun PreferenceGroupScope.radioPreference( title: String, leadingContent: @Composable() (() -> Unit)? = null, onSelected: Boolean = false, enabled: Boolean = true, prefReq: PreferenceRequest, values: Map, onValueChange: (T) -> Boolean = { true } ) { preferences += { shape, modifier -> val scope = rememberCoroutineScope() val dataStoreManager = DataStoreManager(LocalContext.current.dataStore) val value by dataStoreManager.getPreferenceState(prefReq) val setValue = { newValue: T -> scope.launch(Dispatchers.IO) { dataStoreManager.editPreference(prefReq.key, newValue) } onValueChange(newValue) } RadioPreference( modifier = modifier, title = title, summary = values[value], leadingContent = leadingContent, selected = onSelected, shape = shape, enabled = enabled, value = value, values = values, onValueChange = { if (onValueChange(it)) setValue(it) } ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/preferences/items/SwitchPreference.kt ================================================ package dev.aaa1115910.bv.mobile.component.preferences.items import android.content.res.Configuration import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material3.Icon import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.schnettler.datastore.manager.DataStoreManager import de.schnettler.datastore.manager.PreferenceRequest import dev.aaa1115910.bv.dataStore import dev.aaa1115910.bv.mobile.component.preferences.PreferenceGroupScope import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable private fun SwitchPreference( modifier: Modifier = Modifier, title: String, summary: String? = null, onClick: (() -> Unit)? = null, selected: Boolean = false, shape: Shape = RoundedCornerShape(0.dp), enabled: Boolean = true, leadingContent: @Composable() (() -> Unit)? = null, checked: Boolean, onCheckedChange: ((Boolean) -> Unit) ) { BaseListItem( modifier = modifier, headlineContent = { Text(text = title) }, supportingContent = summary?.let { { Text(text = it) } }, selected = selected, enabled = enabled, leadingContent = leadingContent, trailingContent = { Switch( checked = checked, onCheckedChange = onCheckedChange ) }, onClick = onClick, shape = shape ) } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SwitchPreferencePreview() { BVMobileTheme { SwitchPreference( title = "Switch Preference", summary = "This is a summary", leadingContent = { Icon( imageVector = Icons.Default.PlayCircleOutline, contentDescription = null ) }, onClick = { /* Handle click */ }, selected = false, shape = RoundedCornerShape(8.dp), enabled = true, checked = false, onCheckedChange = { /* Handle checked change */ } ) } } fun PreferenceGroupScope.switchPreference( title: String, summary: String? = null, leadingContent: @Composable() (() -> Unit)? = null, onClick: (() -> Unit)? = null, onSelected: Boolean = false, enabled: Boolean = true, checked: Boolean, onCheckedChange: (Boolean) -> Unit ) { preferences += { shape, modifier -> SwitchPreference( modifier = modifier, title = title, summary = summary, leadingContent = leadingContent, onClick = onClick, selected = onSelected, shape = shape, enabled = enabled, checked = checked, onCheckedChange = onCheckedChange ) } } fun PreferenceGroupScope.switchPreference( title: String, summary: String? = null, leadingContent: @Composable() (() -> Unit)? = null, onClick: (() -> Unit)? = null, onSelected: Boolean = false, enabled: Boolean = true, prefReq: PreferenceRequest, onCheckedChange: (Boolean) -> Boolean ) { preferences += { shape, modifier -> val scope = rememberCoroutineScope() val dataStoreManager = DataStoreManager(LocalContext.current.dataStore) val checked by dataStoreManager.getPreferenceState(prefReq) val setChecked = { newValue: Boolean -> scope.launch(Dispatchers.IO) { dataStoreManager.editPreference(prefReq.key, newValue) } onCheckedChange(newValue) } SwitchPreference( modifier = modifier, title = title, summary = summary, leadingContent = leadingContent, onClick = onClick, selected = onSelected, shape = shape, enabled = enabled, checked = checked, onCheckedChange = { if (onCheckedChange(it)) setChecked(it) } ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/preferences/items/TextPreference.kt ================================================ package dev.aaa1115910.bv.mobile.component.preferences.items import android.content.res.Configuration import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.mobile.component.preferences.PreferenceGroupScope import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable private fun TextPreference( modifier: Modifier = Modifier, title: String, summary: String? = null, onClick: (() -> Unit)? = null, selected: Boolean = false, shape: Shape = RoundedCornerShape(0.dp), enabled: Boolean = true, leadingContent: @Composable() (() -> Unit)? = null, trailingContent: @Composable() (() -> Unit)? = null, ) { BaseListItem( modifier = modifier, headlineContent = { Text(text = title) }, supportingContent = summary?.let { { Text(text = it) } }, selected = selected, enabled = enabled, leadingContent = leadingContent, trailingContent = trailingContent, onClick = onClick, shape = shape ) } fun PreferenceGroupScope.textPreference( title: String, summary: String? = null, leadingContent: @Composable() (() -> Unit)? = null, trailingContent: @Composable() (() -> Unit)? = null, onClick: (() -> Unit)? = null, onSelected: Boolean = false, enabled: Boolean = true ) { preferences += { shape, modifier -> TextPreference( modifier = modifier, title = title, summary = summary, leadingContent = leadingContent, trailingContent = trailingContent, onClick = onClick, selected = onSelected, shape = shape, enabled = enabled ) } } fun PreferenceGroupScope.textPreference( title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, selected: Boolean = false, enabled: Boolean = true ) = textPreference( title = title, summary = summary, leadingContent = if (icon != null) (@Composable { Icon( imageVector = icon, contentDescription = null ) }) else null, onClick = onClick, onSelected = selected, enabled = enabled ) @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun TextPreferencePreview() { BVMobileTheme { TextPreference( title = "Text Preference", summary = "This is a summary", leadingContent = { Icon( imageVector = Icons.Default.PlayCircleOutline, contentDescription = null ) }, trailingContent = { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null ) }, onClick = { /* Handle click */ }, selected = false, shape = RoundedCornerShape(8.dp), enabled = true ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/reply/CommentItem.kt ================================================ package dev.aaa1115910.bv.mobile.component.reply import androidx.compose.animation.core.animateIntAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import coil.compose.rememberAsyncImagePainter import com.origeek.imageViewer.previewer.ImagePreviewerState import com.origeek.imageViewer.previewer.TransformImageView import com.origeek.imageViewer.previewer.TransformItemState import com.origeek.imageViewer.previewer.rememberPreviewerState import com.origeek.imageViewer.previewer.rememberTransformItemState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.EmoteSize import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun CommentItem( modifier: Modifier = Modifier, comment: Comment, previewerState: ImagePreviewerState, showReplies: Boolean = true, containerColor: Color = MaterialTheme.colorScheme.surface, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, onShowReply: (rpid: Long) -> Unit = {} ) { Surface( modifier = modifier, color = containerColor ) { Column( modifier = Modifier .padding(vertical = 8.dp) .padding(end = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row { AsyncImage( modifier = Modifier .padding(horizontal = 16.dp) .size(40.dp) .clip(CircleShape) .background(Color.Gray), model = comment.member.avatar, contentDescription = null, contentScale = ContentScale.FillBounds ) Row( modifier = Modifier .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Column { Text( modifier = Modifier .width(200.dp) .basicMarquee(), text = comment.member.name ) Text( text = comment.timeDesc, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } Box { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { //TODO like comment /*IconButton( modifier = Modifier.size(24.dp), onClick = { *//*TODO*//* } ) { Icon( modifier = Modifier.size(20.dp), imageVector = Icons.Outlined.ThumbUpAlt, contentDescription = null ) } Text(text = "233")*/ } } } } Column( modifier = Modifier.padding(start = 72.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { CommentText( content = comment.content, emotes = comment.emotes ) if (comment.pictures.isNotEmpty()) { CommentPictures( pictures = comment.pictures, previewerState = previewerState, onShowPreviewer = onShowPreviewer ) } if (showReplies && (comment.repliesCount != 0 || comment.replies.isNotEmpty())) { CommentReplies( replies = comment.replies, repliesCount = comment.repliesCount, onOpenCommentSheet = { onShowReply(comment.rpid) } ) } } } } } @Composable private fun CommentText( modifier: Modifier = Modifier, content: List, emotes: List, maxLines: Int = 6, showMoreButton: Boolean = true ) { val emoteNameList = emotes.map { it.text } val inlineContentMap = emotes.map { emote -> emote.text to InlineTextContent( Placeholder( width = emote.size.fontSize.sp, height = emote.size.fontSize.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter ) ) { AsyncImage(model = emote.url, contentDescription = null) } }.toMap() var lineCount by remember { mutableIntStateOf(0) } var maxLinesValue by remember { mutableIntStateOf(maxLines) } val currentMaxLines by animateIntAsState(targetValue = maxLinesValue, label = "text max line") var textMoreThan6Lines by remember { mutableStateOf(false) } Column( modifier = modifier ) { Text( text = buildAnnotatedString { content.forEach { text -> if (emoteNameList.contains(text)) { appendInlineContent(text) } else { append(text) } } }, inlineContent = inlineContentMap, maxLines = currentMaxLines, overflow = TextOverflow.Ellipsis, onTextLayout = { textLayoutResult -> if (textLayoutResult.hasVisualOverflow) textMoreThan6Lines = true lineCount = textLayoutResult.lineCount } ) if (showMoreButton && textMoreThan6Lines) { if (maxLinesValue == maxLines) { Text( modifier = Modifier.clickable { maxLinesValue = 999 }, text = "展开", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold ) } else { Text( modifier = Modifier.clickable { maxLinesValue = 6 }, text = "收起", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold ) } } } } @Composable private fun CommentPictures( modifier: Modifier = Modifier, pictures: List, previewerState: ImagePreviewerState, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, ) { val scope = rememberCoroutineScope() val imageBaseShape = MaterialTheme.shapes.medium val onClickPicture: (index: Int, itemState: TransformItemState) -> Unit = { index, itemState -> onShowPreviewer(pictures) { scope.launch { previewerState.openTransform( index = index, itemState = itemState, ) } } } Box( modifier = modifier ) { when { pictures.size == 1 -> { Row { val itemState = rememberTransformItemState() Surface( modifier = Modifier .weight(1f) .aspectRatio(2f), color = Color.Gray, shape = imageBaseShape ) { TransformImageView( modifier = Modifier.clickable { onClickPicture(0, itemState) }, painter = rememberAsyncImagePainter(pictures.first().url), key = pictures.first().key, itemState = itemState, previewerState = previewerState, ) } } } pictures.size == 2 -> { Row( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { pictures.forEachIndexed { index, picture -> val itemState = rememberTransformItemState() Surface( modifier = Modifier .weight(1f) .aspectRatio(1f), color = Color.Gray, shape = when (index) { 0 -> imageBaseShape.copy( topEnd = CornerSize(0.dp), bottomEnd = CornerSize(0.dp) ) 1 -> imageBaseShape.copy( topStart = CornerSize(0.dp), bottomStart = CornerSize(0.dp) ) else -> RoundedCornerShape(0.dp) } ) { TransformImageView( modifier = Modifier.clickable { onClickPicture(index, itemState) }, painter = rememberAsyncImagePainter(picture.url), key = picture.key, itemState = itemState, previewerState = previewerState, ) } } } } pictures.size >= 3 -> { Row( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { pictures.take(3).forEachIndexed { index, picture -> val itemState = rememberTransformItemState() Surface( modifier = Modifier .weight(1f) .aspectRatio(1f), color = Color.Gray, shape = when (index) { 0 -> imageBaseShape.copy( topEnd = CornerSize(0.dp), bottomEnd = CornerSize(0.dp) ) 2 -> imageBaseShape.copy( topStart = CornerSize(0.dp), bottomStart = CornerSize(0.dp) ) else -> RoundedCornerShape(0.dp) } ) { TransformImageView( modifier = Modifier.clickable { onClickPicture(index, itemState) }, painter = rememberAsyncImagePainter(picture.url), key = picture.key, itemState = itemState, previewerState = previewerState, ) } } } if (pictures.size > 3) { Text( modifier = Modifier .align(Alignment.BottomEnd) .clip( MaterialTheme.shapes.medium.copy( topEnd = CornerSize(0.dp), bottomStart = CornerSize(0.dp) ) ) .background(Color.Black.copy(alpha = 0.4f)) .padding(horizontal = 8.dp), text = "+${pictures.size - 3}", color = Color.White ) } } } } } @Composable fun CommentReplies( modifier: Modifier = Modifier, replies: List, repliesCount: Int, onOpenCommentSheet: () -> Unit ) { Surface( shape = MaterialTheme.shapes.medium, onClick = onOpenCommentSheet, color = MaterialTheme.colorScheme.surfaceContainer ) { Column( modifier = modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { replies.forEach { reply -> val replyContent = if (reply.content.firstOrNull()?.startsWith("回复") == true) { listOf("${reply.member.name} ") } else { listOf("${reply.member.name} : ") } + reply.content CommentText( content = replyContent, emotes = reply.emotes, maxLines = 2, showMoreButton = false ) } if (repliesCount > replies.size) { Text( text = "共 $repliesCount 条回复", style = MaterialTheme.typography.bodySmall ) } } } } private class CommentItemPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("单行文字。你好", "[doge]", "World!"), member = Comment.Member(mid = 0, avatar = "", name = "username"), timeDesc = "4小时前", emotes = listOf( Comment.Emote( text = "[doge]", url = "https://i0.hdslb.com/bfs/emote/3087d273a78ccaff4bb1e9972e2ba2a7583c9f11.png", size = EmoteSize.Small ) ), pictures = emptyList(), replies = emptyList(), repliesCount = 0 ), Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("超长评论。If you were a web designer in the early days of the Internet, you might remember that there were few “web safe” typefaces, such as Arial and Georgia. As a result, many websites looked similar. To use a new typeface, you had to embed small Flash files for each heading in your layout."), member = Comment.Member( mid = 0, avatar = "", name = "超长用户名 超长用户名 超长用户名 超长用户名" ), timeDesc = "4小时前", emotes = listOf( Comment.Emote( text = "[doge]", url = "https://i0.hdslb.com/bfs/emote/3087d273a78ccaff4bb1e9972e2ba2a7583c9f11.png", size = EmoteSize.Small ) ), pictures = emptyList(), replies = emptyList(), repliesCount = 0 ), Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("单图片, 1 picture."), member = Comment.Member(mid = 0, avatar = "", name = "username"), timeDesc = "4小时前", emotes = emptyList(), pictures = listOf( Picture( url = "", width = 0, height = 0, key = "" ) ), replies = emptyList(), repliesCount = 0 ), Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("双图片, 2 pictures."), member = Comment.Member(mid = 0, avatar = "", name = "username"), timeDesc = "4小时前", emotes = emptyList(), pictures = listOf( Picture(url = "", width = 0, height = 0, key = "1"), Picture(url = "", width = 0, height = 0, key = "2") ), replies = emptyList(), repliesCount = 0 ), Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("三图片, 3 pictures."), member = Comment.Member(mid = 0, avatar = "", name = "username"), timeDesc = "4小时前", emotes = emptyList(), pictures = listOf( Picture(url = "", width = 0, height = 0, key = "1"), Picture(url = "", width = 0, height = 0, key = "2"), Picture(url = "", width = 0, height = 0, key = "3") ), replies = emptyList(), repliesCount = 0 ), Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("四图片, four pictures."), member = Comment.Member(mid = 0, avatar = "", name = "username"), timeDesc = "4小时前", emotes = emptyList(), pictures = listOf( Picture(url = "", width = 0, height = 0, key = "1"), Picture(url = "", width = 0, height = 0, key = "2"), Picture(url = "", width = 0, height = 0, key = "3"), Picture(url = "", width = 0, height = 0, key = "4") ), replies = emptyList(), repliesCount = 0 ), Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("先兼容后慢慢过渡到完全自主,虽然看起来像安卓套壳,但能避免跨度太大扯到蛋。"), member = Comment.Member(mid = 0, avatar = "", name = "username"), timeDesc = "4小时前", emotes = emptyList(), pictures = listOf( Picture(url = "", width = 0, height = 0, key = "1"), Picture(url = "", width = 0, height = 0, key = "2"), Picture(url = "", width = 0, height = 0, key = "3"), Picture(url = "", width = 0, height = 0, key = "4") ), replies = listOf( Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("其他视频的置顶:美国商务部的源文件里写的很清楚,对于消费用途的产品(consumer application)是exemption(豁免)。但是基于AD102的产品不得在中国大陆生产,也就是说未来国内销售的RTX 4090将会是在境外生产再运输回国内卖,这是唯一的不同点。估计后续也会是商家炒作显卡涨价的理由。"), member = Comment.Member(mid = 0, avatar = "", name = "余Mercury"), timeDesc = "4小时前", emotes = emptyList(), pictures = emptyList(), replies = emptyList(), repliesCount = 0 ), Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("回复 @余Mercury : 中东佬禁酒,用的泡沫水"), member = Comment.Member(mid = 0, avatar = "", name = "铭轩-T"), timeDesc = "4小时前", emotes = emptyList(), pictures = emptyList(), replies = emptyList(), repliesCount = 0 ), Comment( rpid = 0, mid = 0, oid = 0, parent = 0, type = 0, content = listOf("澄清完更好笑了"), member = Comment.Member(mid = 0, avatar = "", name = "Gemini好辣辣"), timeDesc = "4小时前", emotes = emptyList(), pictures = emptyList(), replies = emptyList(), repliesCount = 0 ) ), repliesCount = 0 ) ) } @Preview @Composable private fun CommentItemPreview( @PreviewParameter(CommentItemPreviewParameterProvider::class) comment: Comment ) { val previewerState = rememberPreviewerState(pageCount = { 0 }) BVMobileTheme { CommentItem( comment = comment, previewerState = previewerState, onShowPreviewer = { _, _ -> } ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/reply/Comments.kt ================================================ package dev.aaa1115910.bv.mobile.component.reply import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.origeek.imageViewer.previewer.ImagePreviewerState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.CommentSort @OptIn( ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class ) @Composable fun Comments( modifier: Modifier = Modifier, previewerState: ImagePreviewerState, header: (@Composable () -> Unit)? = null, comments: List, commentSort: CommentSort, isLoading: Boolean, isRefreshing: Boolean, onLoadMoreComments: () -> Unit, onRefreshComments: () -> Unit, onSwitchCommentSort: (CommentSort) -> Unit, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, onShowReplies: (comment: Comment) -> Unit ) { val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() val shouldLoadMore by remember { derivedStateOf { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 10 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) onLoadMoreComments() } PullToRefreshBox( isRefreshing = isRefreshing, state = pullToRefreshState, onRefresh = onRefreshComments ) { LazyColumn( modifier = modifier.fillMaxSize(), state = listState ) { item { header?.invoke() } stickyHeader { Row( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface) .padding(start = 16.dp, end = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = when (commentSort) { CommentSort.Hot -> "热门评论" CommentSort.Time -> "最新评论" else -> "" }, style = MaterialTheme.typography.titleMedium ) TextButton(onClick = { onSwitchCommentSort( when (commentSort) { CommentSort.Hot -> CommentSort.Time CommentSort.Time -> CommentSort.Hot else -> CommentSort.Hot } ) }) { Text( text = when (commentSort) { CommentSort.Hot -> "按热度" CommentSort.Time -> "按时间" else -> "" } ) } } } itemsIndexed(items = comments) { index, comment -> Box { CommentItem( comment = comment, previewerState = previewerState, onShowPreviewer = onShowPreviewer, onShowReply = { _ -> onShowReplies(comment) } ) } } if (comments.isEmpty() && !(isLoading || isRefreshing)) { item { Box( modifier = Modifier .fillMaxWidth() .height(300.dp), contentAlignment = Alignment.Center ) { Text(text = "啥都没有") } } } if (isLoading) { item { Box( modifier = Modifier .fillMaxWidth() .height(100.dp), contentAlignment = Alignment.Center ) { LoadingIndicator() } } } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/reply/Replies.kt ================================================ package dev.aaa1115910.bv.mobile.component.reply import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.origeek.imageViewer.previewer.ImagePreviewerState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.CommentSort import dev.aaa1115910.bv.BuildConfig @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun Replies( modifier: Modifier = Modifier, previewerState: ImagePreviewerState, rootComment: Comment?, replies: List, replySort: CommentSort, repliesCount: Int, isLoading: Boolean, isRefreshing: Boolean, onLoadMoreReplies: () -> Unit, onRefreshReplies: () -> Unit, onSwitchReplySort: (CommentSort) -> Unit, onShowPreviewer: (List, () -> Unit) -> Unit, ) { val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() val shouldLoadMore by remember { derivedStateOf { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 10 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) onLoadMoreReplies() } PullToRefreshBox( modifier = modifier, state = pullToRefreshState, isRefreshing = isRefreshing, onRefresh = onRefreshReplies, ) { LazyColumn( modifier = Modifier.fillMaxHeight(), state = listState ) { if (rootComment != null) { item { CommentItem( comment = rootComment, previewerState = previewerState, onShowPreviewer = onShowPreviewer, showReplies = false ) } } item { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "相关回复共 $repliesCount 条", style = MaterialTheme.typography.titleMedium ) TextButton(onClick = { onSwitchReplySort( when (replySort) { CommentSort.Hot -> CommentSort.Time CommentSort.Time -> CommentSort.Hot else -> CommentSort.Hot } ) }) { Text( text = when (replySort) { CommentSort.Hot -> "按热度" CommentSort.Time -> "按时间" else -> "" } ) } } } itemsIndexed(items = replies) { index, reply -> Box { CommentItem( comment = reply, previewerState = previewerState, onShowPreviewer = onShowPreviewer, showReplies = false ) if (BuildConfig.DEBUG) { Text(text = "$index") } } } if (replies.isEmpty() && !(isLoading || isRefreshing)) { item { Box( modifier = Modifier .fillMaxWidth() .height(300.dp), contentAlignment = Alignment.Center ) { Text(text = "啥都没有") } } } if (isLoading) { item { Box( modifier = Modifier .fillMaxWidth() .height(100.dp), contentAlignment = Alignment.Center ) { LoadingIndicator() } } } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/reply/ReplySheetScaffold.kt ================================================ package dev.aaa1115910.bv.mobile.component.reply import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.origeek.imageViewer.previewer.ImagePreviewerState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.CommentReplyPage import dev.aaa1115910.biliapi.entity.reply.CommentSort import dev.aaa1115910.biliapi.repositories.VideoDetailRepository import dev.aaa1115910.bv.BuildConfig import dev.aaa1115910.bv.util.OnBottomReached import dev.aaa1115910.bv.util.Prefs import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.compose.getKoin @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReplySheetScaffold( modifier: Modifier = Modifier, aid: Long, rpid: Long, repliesCount: Int, sheetState: BottomSheetScaffoldState, previewerState: ImagePreviewerState, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, videoDetailRepository: VideoDetailRepository = getKoin().get(), content: @Composable () -> Unit ) { val scope = rememberCoroutineScope() val listState = rememberLazyListState() val logger = KotlinLogging.logger("ReplySheetScaffold") var nextPage by remember { mutableStateOf(CommentReplyPage()) } var comment by remember { mutableStateOf(null) } val replies = remember { mutableStateListOf() } var sort by remember { mutableStateOf(CommentSort.Time) } var hasNext by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(false) } val loadMoreReply = { if (hasNext && !loading) { loading = true logger.info { "load more reply: [aid=$aid, rpid=$rpid, next=$nextPage]" } scope.launch(Dispatchers.IO) { runCatching { val commentRepliesData = videoDetailRepository.getCommentReplies( aid = aid, commentId = rpid, page = nextPage, sort = sort, preferApiType = Prefs.apiType ) hasNext = commentRepliesData.hasNext nextPage = commentRepliesData.nextPage if (comment == null) comment = commentRepliesData.rootComment replies.addAll(commentRepliesData.replies) }.onFailure { it.printStackTrace() hasNext = false } loading = false } } } val clearData = { hasNext = true comment = null replies.clear() nextPage = CommentReplyPage() } val sheetExpanded by remember { derivedStateOf { sheetState.bottomSheetState.currentValue == SheetValue.Expanded } } if (sheetExpanded) { listState.OnBottomReached(loading = loading) { loadMoreReply() } } val switchCommentSort: (CommentSort) -> Unit = { newSort -> sort = newSort clearData() loadMoreReply() } LaunchedEffect(rpid) { clearData() } LaunchedEffect(sheetState.bottomSheetState.currentValue) { when (sheetState.bottomSheetState.currentValue) { SheetValue.Hidden, SheetValue.PartiallyExpanded -> clearData() SheetValue.Expanded -> {}//loadMoreReply() } } BackHandler( sheetState.bottomSheetState.currentValue != SheetValue.PartiallyExpanded && !(previewerState.canClose || previewerState.animating) ) { scope.launch { sheetState.bottomSheetState.partialExpand() } } BottomSheetScaffold( modifier = modifier .background(MaterialTheme.colorScheme.surfaceContainer) .clip( MaterialTheme.shapes.extraLarge.copy( bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp) ) ), scaffoldState = sheetState, sheetPeekHeight = 0.dp, sheetContent = { LazyColumn( modifier = Modifier.fillMaxHeight(), state = listState ) { if (comment != null) { item { CommentItem( comment = comment!!, previewerState = previewerState, onShowPreviewer = onShowPreviewer, showReplies = false, containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ) } } item { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "相关回复共 $repliesCount 条", style = MaterialTheme.typography.titleMedium ) TextButton(onClick = { switchCommentSort( when (sort) { CommentSort.Hot -> CommentSort.Time CommentSort.Time -> CommentSort.Hot else -> CommentSort.Hot } ) }) { Text( text = when (sort) { CommentSort.Hot -> "按热度" CommentSort.Time -> "按时间" else -> "" } ) } } } itemsIndexed(items = replies) { index, reply -> Box { CommentItem( comment = reply, previewerState = previewerState, onShowPreviewer = onShowPreviewer, showReplies = false, containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ) if (BuildConfig.DEBUG) { Text(text = "$index") } } } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } ) { content() } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/search/PgcCard.kt ================================================ package dev.aaa1115910.bv.mobile.component.search ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/search/UgcCard.kt ================================================ package dev.aaa1115910.bv.mobile.component.search import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.mobile.component.videocard.SmallVideoCard import dev.aaa1115910.bv.mobile.component.videocard.UpIcon import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.resizedImageUrl @Composable fun UgcCard( modifier: Modifier = Modifier, data: VideoCardData, onClick: () -> Unit = {} ) = SmallVideoCard( modifier = modifier, data = data, onClick = onClick ) @Composable fun UgcListItem( modifier: Modifier = Modifier, data: VideoCardData, onClick: () -> Unit = {} ) { Surface( onClick = onClick ) { Row( modifier = modifier .fillMaxWidth() .height(94.dp) .padding(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Box { AsyncImage( modifier = Modifier .fillMaxHeight() .aspectRatio(1.8f) .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceVariant), model = data.cover.resizedImageUrl(ImageSize.SmallVideoCardCover), contentDescription = null, contentScale = ContentScale.FillBounds ) Text( modifier = Modifier .align(Alignment.BottomEnd) .padding(4.dp) .clip(MaterialTheme.shapes.extraSmall) .background(Color.Black.copy(alpha = 0.6f)) .padding(horizontal = 2.dp, vertical = 0.dp), text = data.timeString, color = Color.White, style = MaterialTheme.typography.bodySmall ) } Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { Text( text = data.title, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall ) CompositionLocalProvider( LocalTextStyle provides MaterialTheme.typography.bodySmall ) { Column { Row( verticalAlignment = Alignment.CenterVertically ) { UpIcon(modifier = Modifier.size(16.dp)) Text(text = "bishi") } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, ) Text(text = data.playString) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, ) Text(text = data.danmakuString) } Text(text = data.pubTimeString) } } } } } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun UgcListItemPreview() { BVMobileTheme { UgcListItem( data = previewData ) } } private val previewData = VideoCardData( avid = 0, title = "震惊!太震惊了!真的是太震惊了!我的天呐!真TMD震惊!", cover = "http://i2.hdslb.com/bfs/archive/af17fc07b8f735e822563cc45b7b5607a491dfff.jpg", upName = "bishi", play = 2333, danmaku = 66666, time = 2333 * 1000, pubTime = 1234567890 ) ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/search/UserCard.kt ================================================ package dev.aaa1115910.bv.mobile.component.search ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/settings/UpdateDialog.kt ================================================ package dev.aaa1115910.bv.mobile.component.settings import androidx.compose.material3.Button import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.aaa1115910.bv.component.settings.UpdateDialog @Composable fun UpdateDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit ) { UpdateDialog( modifier = modifier, show = show, onHideDialog = onHideDialog, text = { text -> Text(text = text) }, button = { enabled, onClick, content -> Button( enabled = enabled, onClick = onClick, content = content ) }, outlinedButton = { enabled, onClick, content -> OutlinedButton( enabled = enabled, onClick = onClick, content = content ) } ) } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/user/UserAvatar.kt ================================================ package dev.aaa1115910.bv.mobile.component.user import android.content.res.Configuration import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable fun UserAvatar( modifier: Modifier = Modifier, size: Dp =80.dp, avatar: String ) { Surface( modifier = modifier.size(size), shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, onClick = {} ) { AsyncImage( modifier = Modifier .size(80.dp) .clip(CircleShape), model = avatar, contentDescription = null, contentScale = ContentScale.FillBounds ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun UserAvatarPreview() { BVMobileTheme { Surface { UserAvatar( avatar = "https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg" ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/videocard/RelatedVideoItem.kt ================================================ package dev.aaa1115910.bv.mobile.component.videocard import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.user.Author import dev.aaa1115910.biliapi.entity.video.RelatedVideo import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.formatHourMinSec @Composable fun RelatedVideoItem( modifier: Modifier = Modifier, relatedVideo: RelatedVideo, onClick: (RelatedVideo) -> Unit = {} ) { Surface( modifier = modifier, onClick = { onClick(relatedVideo) }, ) { Row( modifier = Modifier .fillMaxWidth() .height(97.dp) .padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Box { AsyncImage( modifier = Modifier .fillMaxHeight() .aspectRatio(16 / 9f) .background(Color.Gray, MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small), model = relatedVideo.cover, contentDescription = null, contentScale = ContentScale.FillBounds ) Surface( modifier = Modifier .align(Alignment.BottomEnd) .padding(4.dp), color = Color.Black.copy(alpha = 0.3f), shape = MaterialTheme.shapes.extraSmall ) { Text( modifier = Modifier .padding(horizontal = 4.dp), text = (relatedVideo.duration * 1000L).formatHourMinSec(), style = MaterialTheme.typography.bodySmall, color = Color.White ) } } Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { Text( modifier = Modifier.fillMaxWidth(), text = relatedVideo.title, //style = MaterialTheme.typography.titleMedium, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier.size(16.dp), painter = painterResource(id = R.drawable.ic_up), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) Text( text = "${relatedVideo.author?.name}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) } Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) Text( text = "${relatedVideo.view}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) Text( text = "${relatedVideo.danmaku}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) } } } } } } @Preview @Composable fun RelatedVideoItemPreview() { BVMobileTheme { Surface { RelatedVideoItem( relatedVideo = RelatedVideo( aid = 0, title = "This is a video title! This is a video title! This is a video title! ", cover = "", author = Author( mid = 0, name = "Up name", face = "", ), duration = 5346, view = 3521, danmaku = 543, jumpToSeason = false, epid = null ) ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/videocard/SeasonCard.kt ================================================ package dev.aaa1115910.bv.mobile.component.videocard import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable fun SeasonCard( modifier: Modifier = Modifier, data: SeasonCardData, coverHeight: Dp? = null, onClick: () -> Unit = {}, ) { val localDensity = LocalDensity.current var coverRealWidth by remember { mutableStateOf(0.dp) } Card( modifier = modifier, onClick = onClick, shape = MaterialTheme.shapes.large, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) ) { Column { val coverModifier = if (coverHeight != null) { Modifier.height(coverHeight) } else { Modifier.fillMaxWidth() } val textBoxModifier = if (coverHeight != null) { Modifier.width((0.75 * coverHeight.value).dp) } else { Modifier } Box( modifier = Modifier.clip(MaterialTheme.shapes.large), contentAlignment = Alignment.BottomCenter ) { AsyncImage( modifier = coverModifier .aspectRatio(0.75f) .clip(MaterialTheme.shapes.large) .onGloballyPositioned { coordinates -> coverRealWidth = with(localDensity) { coordinates.size.width.toDp() } }, model = data.cover, contentDescription = null, contentScale = ContentScale.FillBounds ) if (data.rating != null) { Box( modifier = Modifier .height(48.dp) // 无法使用 fillMaxWidth 来确定宽度 .width(coverRealWidth) .background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.8f) ) ) ) ) Text( modifier = Modifier .align(Alignment.BottomEnd) .fillMaxWidth() .padding(8.dp, 0.dp), text = data.rating ?: "", fontStyle = FontStyle.Italic, fontWeight = FontWeight.Bold, fontSize = 24.sp, textAlign = TextAlign.End, color = Color.White ) } } Column( modifier = textBoxModifier.padding(8.dp) ) { Text( text = data.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) if (data.subTitle != null) { Text( text = data.subTitle!!, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } } } @Preview(device = "id:tv_1080p") @Composable private fun SeasonCardPreview() { BVMobileTheme { LazyVerticalGrid(columns = GridCells.Fixed(6)) { repeat(6) { item { SeasonCard( data = SeasonCardData( seasonId = 40794, title = "007:没空去死", cover = "http://i0.hdslb.com/bfs/bangumi/image/8d211c396aad084d6fa413015200dda6ed260768.png", rating = "8.6" ) ) } } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/videocard/SmallVideoCard.kt ================================================ package dev.aaa1115910.bv.mobile.component.videocard import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.resizedImageUrl @OptIn(ExperimentalMaterial3Api::class) @Composable fun SmallVideoCard( modifier: Modifier = Modifier, data: VideoCardData, onClick: () -> Unit = {}, ) { Card( modifier = modifier, onClick = onClick, shape = MaterialTheme.shapes.large, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) ) { Column { Box( modifier = Modifier.clip(MaterialTheme.shapes.large), contentAlignment = Alignment.BottomCenter ) { AsyncImage( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f) .clip(MaterialTheme.shapes.large), model = data.cover.resizedImageUrl(ImageSize.SmallVideoCardCover), contentDescription = null, contentScale = ContentScale.FillBounds ) Box( modifier = Modifier .fillMaxWidth() .height(48.dp) .background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.8f) ) ) ) ) Row( modifier = Modifier .fillMaxWidth() .padding(12.dp, 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (data.playString != "") { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = Color.White ) Text( text = data.playString, style = MaterialTheme.typography.bodySmall, color = Color.White ) } } if (data.danmakuString != "") { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, tint = Color.White ) Text( text = data.danmakuString, style = MaterialTheme.typography.bodySmall, color = Color.White ) } } } Text( text = data.timeString, style = MaterialTheme.typography.bodySmall, color = Color.White ) } } Column( modifier = Modifier.padding(8.dp) ) { Text( text = data.title, style = MaterialTheme.typography.titleSmall, maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis ) Row( verticalAlignment = Alignment.CenterVertically ) { UpIcon() Text( text = data.upName, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } } } @Preview @Composable fun SmallVideoCardPreview() { val data = VideoCardData( avid = 0, title = "震惊!太震惊了!真的是太震惊了!我的天呐!真TMD震惊!", cover = "http://i2.hdslb.com/bfs/archive/af17fc07b8f735e822563cc45b7b5607a491dfff.jpg", upName = "bishi", play = 2333, danmaku = 666, time = 2333 * 1000 ) BVMobileTheme { Surface { SmallVideoCard( data = data ) } } } @Preview @Composable fun SmallVideoCardsPreview() { val data = VideoCardData( avid = 0, title = "震惊!太震惊了!真的是太震惊了!我的天呐!真TMD震惊!", cover = "http://i2.hdslb.com/bfs/archive/af17fc07b8f735e822563cc45b7b5607a491dfff.jpg", upName = "bishi", play = 2333, danmaku = 666, time = 2333 * 1000 ) BVMobileTheme { Surface { LazyVerticalGrid( columns = GridCells.Fixed(2) ) { repeat(20) { item { SmallVideoCard( data = data ) } } } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/videocard/UpIcon.kt ================================================ package dev.aaa1115910.bv.mobile.component.videocard import androidx.compose.foundation.layout.Row import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable fun UpIcon( modifier: Modifier = Modifier, //color: Color = MaterialTheme.colorScheme.onSurface ) { Icon( modifier = modifier, painter = painterResource(id = R.drawable.ic_up), contentDescription = null, //tint = color ) } @Preview @Composable fun UpIconPreview() { BVMobileTheme { Row( verticalAlignment = Alignment.CenterVertically ) { UpIcon() Text(text = "bishi") } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/component/videocard/UpSpaceVideoItem.kt ================================================ package dev.aaa1115910.bv.mobile.component.videocard import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.user.SpaceVideo import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.formatHourMinSec import dev.aaa1115910.bv.util.formatPubTimeString import java.util.Date @Composable fun UpSpaceVideoItem( modifier: Modifier = Modifier, spaceVideo: SpaceVideo, onClick: (SpaceVideo) -> Unit = {} ) { val context = LocalContext.current Surface( modifier = modifier, onClick = { onClick(spaceVideo) }, ) { Row( modifier = Modifier .fillMaxWidth() .height(97.dp) .padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Box { AsyncImage( modifier = Modifier .fillMaxHeight() .aspectRatio(16 / 9f) .background(Color.Gray, MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small), model = spaceVideo.cover, contentDescription = null, contentScale = ContentScale.FillBounds ) Surface( modifier = Modifier .align(Alignment.BottomEnd) .padding(4.dp), color = Color.Black.copy(alpha = 0.3f), shape = MaterialTheme.shapes.extraSmall ) { Text( modifier = Modifier .padding(horizontal = 4.dp), text = (spaceVideo.duration * 1000L).formatHourMinSec(), style = MaterialTheme.typography.bodySmall, color = Color.White ) } } Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { Text( modifier = Modifier.fillMaxWidth(), text = spaceVideo.title, //style = MaterialTheme.typography.titleMedium, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis ) Text( text = spaceVideo.publishDate.formatPubTimeString(context), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) Text( text = "${spaceVideo.play}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) Text( text = "${spaceVideo.danmaku}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) } } } } } } @Preview @Composable fun UpSpaceVideoItemPreview() { BVMobileTheme { Surface { UpSpaceVideoItem( spaceVideo = SpaceVideo( aid = 0, bvid = "", title = "This is a video title! This is a video title! This is a video title!", cover = "", author = "", duration = 2345, play = 4342, danmaku = 634, publishDate = Date() ) ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/DynamicDetailScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import android.content.Context import androidx.activity.BackEventCompat import androidx.activity.compose.BackHandler import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.expandHorizontally import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import coil.size.Size import com.origeek.imageViewer.previewer.ImagePreviewer import com.origeek.imageViewer.previewer.ImagePreviewerState import com.origeek.imageViewer.previewer.VerticalDragType import com.origeek.imageViewer.previewer.rememberPreviewerState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.CommentSort import dev.aaa1115910.biliapi.entity.user.DynamicItem import dev.aaa1115910.bv.mobile.component.home.dynamic.DynamicContent import dev.aaa1115910.bv.mobile.component.home.dynamic.DynamicHeader import dev.aaa1115910.bv.mobile.component.reply.Comments import dev.aaa1115910.bv.mobile.component.reply.Replies import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.swapList import dev.aaa1115910.bv.viewmodel.CommentViewModel import dev.aaa1115910.bv.viewmodel.DynamicDetailViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import kotlin.math.min @OptIn( ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class ) @Composable fun DynamicDetailScreen( modifier: Modifier = Modifier, dynamicDetailViewModel: DynamicDetailViewModel = koinViewModel(), commentViewModel: CommentViewModel = koinViewModel() ) { val context = LocalContext.current val windowSizeClass = calculateWindowSizeClass(context as Activity) val pictures = remember { mutableStateListOf() } val dynamicDetailState = rememberDynamicDetailState( dynamicDetailViewModel = dynamicDetailViewModel, commentViewModel = commentViewModel, imagePreviewerState = rememberPreviewerState( verticalDragType = VerticalDragType.UpAndDown, pageCount = { pictures.size }, getKey = { pictures[it].key } ) ) val onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit = { newPictures, afterSetPictures -> pictures.swapList(newPictures) afterSetPictures() } if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded) { DynamicDetailMobileContent( modifier = modifier, dynamicDetailState = dynamicDetailState, onShowPreviewer = onShowPreviewer, ) } else { DynamicDetailScreenPadContent( modifier = modifier, dynamicDetailState = dynamicDetailState, onShowPreviewer = onShowPreviewer, ) } ImagePreviewer( modifier = Modifier .fillMaxSize(), state = dynamicDetailState.imagePreviewerState, imageLoader = { index -> val imageRequest = ImageRequest.Builder(context) .data(pictures[index].url) .size(Size.ORIGINAL) .build() rememberAsyncImagePainter(imageRequest) } ) } @OptIn( ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3ExpressiveApi::class ) @Composable fun DynamicDetailMobileContent( modifier: Modifier = Modifier, dynamicDetailState: DynamicDetailState, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit ) { val context = LocalContext.current val density = LocalDensity.current val logger = KotlinLogging.logger { } val screenHeight = with(density) { context.resources.displayMetrics.heightPixels.toDp() } var showMask by remember { mutableStateOf(false) } var showReplies by remember { mutableStateOf(false) } val onRepliesCloseAnimationFinish: (Dp) -> Unit = { finishDp -> logger.fInfo { "onRepliesCloseAnimationFinish: $finishDp" } if (finishDp == screenHeight) { showReplies = false showMask = false } } var maskAlphaTarget by remember { mutableFloatStateOf(0.5f) } val maskAlpha by animateFloatAsState( targetValue = maskAlphaTarget, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "replies scrim mask alpha" ) var repliesOffsetYTarget by remember { mutableStateOf(0.dp) } val repliesOffsetY by animateDpAsState( targetValue = repliesOffsetYTarget, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "replies offset y", finishedListener = onRepliesCloseAnimationFinish ) var repliesScaleTarget by remember { mutableFloatStateOf(1f) } val repliesScale by animateFloatAsState( targetValue = repliesScaleTarget, label = "replies scale" ) val repliesRoundCorner by animateDpAsState( targetValue = if (repliesScaleTarget == 1f) 0.dp else 28.dp, label = "replies round corner" ) val onCloseReplies: () -> Unit = { maskAlphaTarget = 0f repliesOffsetYTarget = screenHeight } LaunchedEffect(showMask) { maskAlphaTarget = if (showMask) 0.5f else 0f } LaunchedEffect(showReplies) { repliesOffsetYTarget = if (showReplies) 0.dp else screenHeight if (showReplies) repliesScaleTarget = 1f showMask = showReplies } PredictiveBackHandler(showMask) { progress: Flow -> runCatching { progress.collect { backEvent -> maskAlphaTarget = (1 - backEvent.progress * 0.8f) * 0.5f repliesOffsetYTarget = (backEvent.progress * 200).dp repliesScaleTarget = 1 - min(0.6f, backEvent.progress) * 0.2f } onCloseReplies() }.onFailure { maskAlphaTarget = 0.5f repliesOffsetYTarget = 0.dp } } Box( modifier = modifier.fillMaxSize() ) { Scaffold( topBar = { TopAppBar( title = { Text("Dynamic Detail") }, navigationIcon = { IconButton(onClick = dynamicDetailState.onExitActivity) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null ) } } ) } ) { innerPadding -> if (dynamicDetailState.dynamicItem != null) { CommentPart( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), previewerState = dynamicDetailState.imagePreviewerState, comments = dynamicDetailState.comments, commentSort = dynamicDetailState.commentSort, isLoading = dynamicDetailState.isLoadingComments, isRefreshing = dynamicDetailState.isRefreshingComments, onLoadMoreComments = dynamicDetailState::loadMoreComments, onRefreshComments = dynamicDetailState::refreshComments, onSwitchCommentSort = dynamicDetailState::switchCommentSort, onShowPreviewer = onShowPreviewer, onShowReplies = { comment -> dynamicDetailState.updateCurrentComment(comment) dynamicDetailState.refreshReplies() showReplies = true }, header = { DynamicPart( modifier = Modifier, dynamicItem = dynamicDetailState.dynamicItem, previewerState = dynamicDetailState.imagePreviewerState, onShowPreviewer = onShowPreviewer ) } ) } else { LoadingIndicator() } } // Dark mask if (showMask) Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = maskAlpha)) .clickable( interactionSource = null, indication = null, onClick = { } ) ) {} // replies if (showReplies) { ReplyPart( modifier = Modifier .offset(y = repliesOffsetY) .scale(repliesScale) .clip( RoundedCornerShape( topStart = repliesRoundCorner, topEnd = repliesRoundCorner ) ), comment = dynamicDetailState.replyComment, sort = dynamicDetailState.replySort, replies = dynamicDetailState.replies, previewerState = dynamicDetailState.imagePreviewerState, repliesCount = dynamicDetailState.replyComment?.repliesCount ?: 0, isLoading = dynamicDetailState.isLoadingReplies, isRefreshing = dynamicDetailState.isRefreshingReplies, onShowPreviewer = onShowPreviewer, onCloseReplies = onCloseReplies, onSwitchSort = dynamicDetailState::switchReplySort, onRefreshReplies = dynamicDetailState::refreshReplies, onLoadMoreReplies = dynamicDetailState::loadMoreReplies, ) } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) @Composable fun DynamicDetailScreenPadContent( modifier: Modifier = Modifier, dynamicDetailState: DynamicDetailState, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, ) { val context = LocalContext.current val density = LocalDensity.current val screenWidth = with(density) { context.resources.displayMetrics.widthPixels.toDp() } var showReplies by remember { mutableStateOf(false) } Scaffold( modifier = modifier, topBar = { TopAppBar( title = { Text("Dynamic Detail") }, navigationIcon = { IconButton(onClick = dynamicDetailState.onExitActivity) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null ) } } ) } ) { innerPadding -> Box( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()) ) { Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center ) { DynamicPart( modifier = Modifier .width(screenWidth / 3 - 10.dp) .verticalScroll(rememberScrollState()), dynamicItem = dynamicDetailState.dynamicItem, previewerState = dynamicDetailState.imagePreviewerState, onShowPreviewer = onShowPreviewer ) AnimatedVisibility( visible = dynamicDetailState.dynamicItem != null, enter = expandHorizontally(), exit = shrinkHorizontally() ) { CommentPart( modifier = Modifier.width(screenWidth / 3 - 10.dp), previewerState = dynamicDetailState.imagePreviewerState, comments = dynamicDetailState.comments, commentSort = dynamicDetailState.commentSort, isLoading = dynamicDetailState.isLoadingComments, isRefreshing = dynamicDetailState.isRefreshingComments, onLoadMoreComments = dynamicDetailState::loadMoreComments, onRefreshComments = dynamicDetailState::refreshComments, onSwitchCommentSort = dynamicDetailState::switchCommentSort, onShowPreviewer = onShowPreviewer, onShowReplies = { comment -> dynamicDetailState.updateCurrentComment(comment) dynamicDetailState.refreshReplies() showReplies = true } ) } AnimatedVisibility( visible = showReplies, enter = expandHorizontally(), exit = shrinkHorizontally() ) { ReplyPart( modifier = Modifier.width(screenWidth / 3 - 10.dp), comment = dynamicDetailState.replyComment, sort = dynamicDetailState.replySort, replies = dynamicDetailState.replies, previewerState = dynamicDetailState.imagePreviewerState, repliesCount = dynamicDetailState.replyComment?.repliesCount ?: 0, isLoading = dynamicDetailState.isLoadingReplies, isRefreshing = dynamicDetailState.isRefreshingReplies, enableTopPadding = false, onShowPreviewer = onShowPreviewer, onCloseReplies = { showReplies = false }, onSwitchSort = dynamicDetailState::switchReplySort, onRefreshReplies = dynamicDetailState::refreshReplies, onLoadMoreReplies = dynamicDetailState::loadMoreReplies, ) } } } } } data class DynamicDetailState( val context: Context, val scope: CoroutineScope, val dynamicDetailViewModel: DynamicDetailViewModel, val commentViewModel: CommentViewModel, val imagePreviewerState: ImagePreviewerState ) { val dynamicItem get() = dynamicDetailViewModel.dynamicItem val comments get() = commentViewModel.comments val replies get() = commentViewModel.replies var replyComment by mutableStateOf(null) val commentSort get() = commentViewModel.commentSort val replySort get() = commentViewModel.replySort val isRefreshingComments get() = commentViewModel.refreshingComments val isRefreshingReplies get() = commentViewModel.refreshingReplies val isLoadingComments get() = commentViewModel.updatingComments val isLoadingReplies get() = commentViewModel.updatingReplies val hasMoreComments get() = commentViewModel.hasMoreComments val hasMoreReplies get() = commentViewModel.hasMoreReplies fun loadMoreComments() { scope.launch(Dispatchers.IO) { commentViewModel.loadMoreComment() } } fun loadMoreReplies() { scope.launch(Dispatchers.IO) { commentViewModel.loadMoreReplies() } } fun updateCurrentComment(comment: Comment) { replyComment = comment commentViewModel.commentId = comment.oid commentViewModel.commentType = comment.type commentViewModel.rpid = comment.rpid } fun switchCommentSort(newSort: CommentSort) { scope.launch(Dispatchers.IO) { commentViewModel.switchCommentSort(newSort) } } fun switchReplySort(newSort: CommentSort) { scope.launch(Dispatchers.IO) { commentViewModel.switchReplySort(newSort) } } fun refreshComments() { scope.launch(Dispatchers.IO) { commentViewModel.refreshComments() } } fun refreshReplies() { scope.launch(Dispatchers.IO) { commentViewModel.refreshReplies() } } val onExitActivity: () -> Unit = { (context as Activity).finish() } } @Composable fun rememberDynamicDetailState( dynamicDetailViewModel: DynamicDetailViewModel, commentViewModel: CommentViewModel, imagePreviewerState: ImagePreviewerState ): DynamicDetailState { val context = LocalContext.current val scope = rememberCoroutineScope() BackHandler(imagePreviewerState.canClose || imagePreviewerState.animating) { if (imagePreviewerState.canClose) scope.launch { imagePreviewerState.closeTransform() } } LaunchedEffect(Unit) { val intent = (context as Activity).intent val dynamicId = intent.getStringExtra("dynamicId") dynamicId?.let { dynamicDetailViewModel.dynamicId = dynamicId } ?: context.finish() scope.launch(Dispatchers.IO) { dynamicDetailViewModel.loadDynamic() if (dynamicDetailViewModel.dynamicItem?.commentId != null && dynamicDetailViewModel.dynamicItem?.commentType != null) { commentViewModel.commentId = dynamicDetailViewModel.dynamicItem!!.commentId commentViewModel.commentType = dynamicDetailViewModel.dynamicItem!!.commentType //commentViewModel.loadMoreComment() } } } return remember( dynamicDetailViewModel, commentViewModel, imagePreviewerState ) { DynamicDetailState( context = context, scope = scope, dynamicDetailViewModel = dynamicDetailViewModel, commentViewModel = commentViewModel, imagePreviewerState = imagePreviewerState ) } } @Composable private fun DynamicPart( modifier: Modifier = Modifier, dynamicItem: DynamicItem?, previewerState: ImagePreviewerState, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit ) { Column( modifier = modifier ) { if (dynamicItem != null) { DynamicHeader( modifier = Modifier .padding(12.dp), author = dynamicItem.author ) } if (dynamicItem != null) { DynamicContent( modifier = Modifier .padding( bottom = WindowInsets.navigationBars.asPaddingValues() .calculateBottomPadding() ), dynamicItem = dynamicItem, previewerState = previewerState, onShowPreviewer = onShowPreviewer, onClick = { } ) } } } @Composable private fun CommentPart( modifier: Modifier = Modifier, header: (@Composable () -> Unit)? = null, previewerState: ImagePreviewerState, comments: List, commentSort: CommentSort, isLoading: Boolean, isRefreshing: Boolean, onLoadMoreComments: () -> Unit, onRefreshComments: () -> Unit, onSwitchCommentSort: (CommentSort) -> Unit, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, onShowReplies: (comment: Comment) -> Unit ) { Comments( modifier = modifier, header = header, previewerState = previewerState, comments = comments, commentSort = commentSort, isLoading = isLoading, isRefreshing = isRefreshing, onLoadMoreComments = onLoadMoreComments, onRefreshComments = onRefreshComments, onSwitchCommentSort = onSwitchCommentSort, onShowPreviewer = onShowPreviewer, onShowReplies = onShowReplies, ) } @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun ReplyPart( modifier: Modifier = Modifier, previewerState: ImagePreviewerState, comment: Comment?, sort: CommentSort, replies: List, repliesCount: Int, isLoading: Boolean, isRefreshing: Boolean, enableTopPadding: Boolean = true, onSwitchSort: (CommentSort) -> Unit, onShowPreviewer: (List, () -> Unit) -> Unit, onCloseReplies: () -> Unit, onRefreshReplies: () -> Unit, onLoadMoreReplies: () -> Unit ) { Scaffold( modifier = modifier .fillMaxSize(), topBar = { TopAppBar( modifier = Modifier, title = { Text("Replies") }, navigationIcon = { IconButton(onClick = onCloseReplies) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null ) } }, windowInsets = if (enableTopPadding) TopAppBarDefaults.windowInsets else WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) ) } ) { innerPadding -> Replies( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), previewerState = previewerState, rootComment = comment, replySort = sort, replies = replies, repliesCount = repliesCount, isLoading = isLoading, isRefreshing = isRefreshing, onSwitchReplySort = onSwitchSort, onShowPreviewer = onShowPreviewer, onLoadMoreReplies = onLoadMoreReplies, onRefreshReplies = onRefreshReplies, ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/FavoriteScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.FavoriteFolderMetadata import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.mobile.activities.VideoPlayerActivity import dev.aaa1115910.bv.mobile.component.videocard.SmallVideoCard import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.OnBottomReached import dev.aaa1115910.bv.util.calculateWindowSizeClassInPreview import dev.aaa1115910.bv.viewmodel.user.FavoriteViewModel import org.koin.androidx.compose.koinViewModel @Composable fun FavoriteScreen( modifier: Modifier = Modifier, windowSize: WindowSizeClass, favoriteViewModel: FavoriteViewModel = koinViewModel() ) { val context = LocalContext.current val listState = rememberLazyGridState() val currentTabIndex by remember { derivedStateOf { favoriteViewModel.favoriteFolderMetadataList.indexOf(favoriteViewModel.currentFavoriteFolderMetadata) } } if (favoriteViewModel.favoriteFolderMetadataList.isNotEmpty() && favoriteViewModel.favorites.isNotEmpty()) { listState.OnBottomReached( loading = favoriteViewModel.updatingFolderItems, ) { favoriteViewModel.updateFolderItems() } } LaunchedEffect(currentTabIndex) { favoriteViewModel.favorites.clear() favoriteViewModel.updateFolderItems(force = true) } FavoriteContent( modifier = modifier, listState = listState, windowSize = windowSize, selectedTabIndex = currentTabIndex, favoriteFolders = favoriteViewModel.favoriteFolderMetadataList, favorites = favoriteViewModel.favorites, onClickTab = { folderMetadata -> favoriteViewModel.currentFavoriteFolderMetadata = folderMetadata }, onClickVideo = { videoCardData -> VideoPlayerActivity.actionStart( context = context, aid = videoCardData.avid, ) }, onBack = { (context as Activity).finish() } ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FavoriteContent( modifier: Modifier = Modifier, listState: LazyGridState = rememberLazyGridState(), windowSize: WindowSizeClass, selectedTabIndex: Int, favoriteFolders: List, favorites: List, onClickTab: (FavoriteFolderMetadata) -> Unit, onClickVideo: (VideoCardData) -> Unit, onBack: () -> Unit ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { Column { LargeTopAppBar( title = { Text(text = stringResource(id = R.string.title_mobile_activity_favorite)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null ) } }, scrollBehavior = scrollBehavior ) if (favoriteFolders.isNotEmpty()) { PrimaryScrollableTabRow( selectedTabIndex = selectedTabIndex, divider = { }, ) { favoriteFolders.forEachIndexed { index, folderMetadata -> Tab( selected = selectedTabIndex == index, onClick = { onClickTab(folderMetadata) } ) { Box( modifier = Modifier.height(48.dp), contentAlignment = Alignment.Center ) { Text( modifier = Modifier.padding(horizontal = 16.dp), text = folderMetadata.title, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) } } } } HorizontalDivider() } } } ) { innerPadding -> LazyVerticalGrid( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), state = listState, columns = GridCells.Adaptive(180.dp), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { itemsIndexed(items = favorites) { index, data -> SmallVideoCard( data = data, onClick = { onClickVideo(data) } ) } } } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Preview(device = "spec:width=411dp,height=891dp") @Preview(device = "spec:width=1280dp,height=800dp,dpi=240") @Composable private fun FavoriteContentPreview() { val windowSize = calculateWindowSizeClassInPreview() val favoriteFolderSize = 10 var currentFavoriteFolderMetadata by remember { mutableStateOf(null) } val favoriteFolderMetadataList = (1..favoriteFolderSize).map { FavoriteFolderMetadata( id = it.toLong(), fid = it.toLong(), mid = 0, title = "folder$it", cover = null, videoInThisFav = false, mediaCount = (30..50).random() ) } val generateFavorites: (Long) -> List = { folderId -> (1..(currentFavoriteFolderMetadata?.mediaCount ?: 50)).map { VideoCardData( avid = it.toLong(), title = "folder$folderId video$it", cover = "", play = it * 1000, danmaku = it * 100, upName = "upName$it", time = it * 1000L ) } } val currentTabIndex by remember { derivedStateOf { favoriteFolderMetadataList.indexOf(currentFavoriteFolderMetadata) .takeIf { it != -1 } ?: 0 } } val favorites by remember { derivedStateOf { generateFavorites(currentFavoriteFolderMetadata?.id ?: 0) } } BVMobileTheme { FavoriteContent( windowSize = windowSize, selectedTabIndex = currentTabIndex, favoriteFolders = favoriteFolderMetadataList, favorites = favorites, onClickTab = { currentFavoriteFolderMetadata = it }, onClickVideo = {}, onBack = {} ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/FollowingSeasonScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.season.FollowingSeasonType import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.mobile.component.videocard.SeasonCard import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.OnBottomReached import dev.aaa1115910.bv.util.calculateWindowSizeClassInPreview import dev.aaa1115910.bv.util.getDisplayName import dev.aaa1115910.bv.viewmodel.user.FollowingSeasonViewModel import org.koin.androidx.compose.koinViewModel @Composable fun FollowingSeasonScreen( modifier: Modifier = Modifier, windowSize: WindowSizeClass, followingSeasonViewModel: FollowingSeasonViewModel = koinViewModel() ) { val context = LocalContext.current val listState = rememberLazyGridState() listState.OnBottomReached( loading = followingSeasonViewModel.updating ) { if (followingSeasonViewModel.noMore) return@OnBottomReached followingSeasonViewModel.loadMore() } FollowingSeasonContent( modifier = modifier, windowSize = windowSize, type = followingSeasonViewModel.followingSeasonType, seasons = followingSeasonViewModel.followingSeasons.map(SeasonCardData::fromFollowingSeason), onBack = { (context as Activity).finish() }, onTypeChange = { followingSeasonViewModel.followingSeasonType = it followingSeasonViewModel.clearData() followingSeasonViewModel.loadMore() }, onClickSeason = {} ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FollowingSeasonContent( modifier: Modifier = Modifier, windowSize: WindowSizeClass, type: FollowingSeasonType, seasons: List, onBack: () -> Unit, onTypeChange: (FollowingSeasonType) -> Unit, onClickSeason: (SeasonCardData) -> Unit, ) { val context = LocalContext.current val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) Scaffold( modifier = modifier .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { Column { LargeTopAppBar( title = { Text(text = "我的追番") }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null ) } }, scrollBehavior = scrollBehavior ) PrimaryTabRow( selectedTabIndex = type.ordinal, ) { FollowingSeasonType.entries.forEach { seasonType -> Tab( selected = type == seasonType, text = { Text(text = seasonType.getDisplayName(context)) }, onClick = { onTypeChange(seasonType) } ) } } } }, ) { innerPadding -> LazyVerticalGrid( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), columns = GridCells.Adaptive(100.dp), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { itemsIndexed(seasons) { index, season -> SeasonCard( data = season, onClick = { onClickSeason(season) } ) } } } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Preview @Composable private fun FollowingSeasonContentPreview() { val windowSize = calculateWindowSizeClassInPreview() var selectedType by remember { mutableStateOf(FollowingSeasonType.Bangumi) } val seasons = (1..50).map { SeasonCardData( seasonId = it, title = "Title $it", cover = "http://i0.hdslb.com/bfs/bangumi/image/8d211c396aad084d6fa413015200dda6ed260768.png", rating = "8.6" ) } BVMobileTheme { FollowingSeasonContent( windowSize = windowSize, type = selectedType, seasons = seasons, onBack = {}, onTypeChange = { selectedType = it }, onClickSeason = {} ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/FollowingUserScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.user.FollowedUser import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.activities.UserSpaceActivity import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.viewmodel.user.FollowViewModel import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun FollowingUserScreen( modifier: Modifier = Modifier, followViewModel: FollowViewModel = koinViewModel(), ) { val context = LocalContext.current val windowSizeClass = calculateWindowSizeClass(context as Activity) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val onClickUser: (FollowedUser) -> Unit = { followedUser -> UserSpaceActivity.actionStart( context = context, mid = followedUser.mid, name = followedUser.name ) } Scaffold( modifier = modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(text = stringResource(R.string.title_mobile_activity_following_user)) }, navigationIcon = { IconButton(onClick = { context.finish() }) { Icon( imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null ) } }, scrollBehavior = scrollBehavior ) } ) { innerPadding -> if (followViewModel.updating) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Loading() } } if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded) { LazyColumn( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), ) { if (!followViewModel.updating) { items(items = followViewModel.followedUsers) { followedUser -> FollowingUserListItem( followedUser = followedUser, onClick = onClickUser ) } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } } else { LazyVerticalGrid( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), columns = GridCells.Fixed(2) ) { if (!followViewModel.updating) { items(items = followViewModel.followedUsers) { followedUser -> FollowingUserListItem( followedUser = followedUser, onClick = onClickUser ) } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } } } } @Composable private fun FollowingUserListItem( modifier: Modifier = Modifier, followedUser: FollowedUser, onClick: (FollowedUser) -> Unit = {} ) { ListItem( modifier = modifier .clickable { onClick(followedUser) }, headlineContent = { Text(text = followedUser.name) }, supportingContent = { Text( text = followedUser.sign, maxLines = 2, overflow = TextOverflow.Ellipsis ) }, leadingContent = { AsyncImage( modifier = Modifier .size(48.dp) .background(Color.Gray, CircleShape) .clip(CircleShape), model = followedUser.avatar, contentDescription = null, contentScale = ContentScale.FillBounds ) } ) } @Composable private fun Loading( modifier: Modifier = Modifier ) { Column( modifier = modifier .fillMaxWidth() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp) ) { Text(text = "Loading") LinearProgressIndicator( modifier = Modifier.fillMaxWidth() ) } } @Preview @Composable private fun FollowingUserListItemPreview() { BVMobileTheme { FollowingUserListItem( followedUser = FollowedUser( mid = 1L, name = "UP name", avatar = "https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg@450w_450h_progressive.webp", sign = "This is a sign" ) ) } } @Preview @Composable private fun LoadingPreview() { BVMobileTheme { Surface { Loading() } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/HistoryScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.activities.VideoPlayerActivity import dev.aaa1115910.bv.mobile.component.videocard.SmallVideoCard import dev.aaa1115910.bv.util.OnBottomReached import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.viewmodel.user.HistoryViewModel import io.github.oshai.kotlinlogging.KotlinLogging import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun HistoryScreen( modifier: Modifier = Modifier, windowSize: WindowSizeClass, historyViewModel: HistoryViewModel = koinViewModel() ) { val context = LocalContext.current val logger = KotlinLogging.logger("HistoryScreen") val listState = rememberLazyGridState() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) listState.OnBottomReached( loading = historyViewModel.updating ) { logger.fInfo { "on reached rcmd page bottom" } historyViewModel.update() } Scaffold( modifier = modifier .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(text = stringResource(R.string.title_mobile_activity_history)) }, navigationIcon = { IconButton( onClick = { (context as Activity).finish() } ) { Icon( imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null ) } }, scrollBehavior = scrollBehavior ) } ) { innerPadding -> LazyVerticalGrid( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), columns = GridCells.Adaptive(if (windowSize.widthSizeClass == WindowWidthSizeClass.Compact) 180.dp else 220.dp), state = listState, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp) ) { items(historyViewModel.histories) { history -> SmallVideoCard( data = history, onClick = { VideoPlayerActivity.actionStart( context = context, aid = history.avid ) } ) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/LoginScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.geetest.sdk.GT3ConfigBean import com.geetest.sdk.GT3ErrorBean import com.geetest.sdk.GT3GeetestUtils import com.geetest.sdk.GT3Listener import dev.aaa1115910.biliapi.entity.login.QrLoginState import dev.aaa1115910.biliapi.repositories.SendSmsState import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.calculateWindowSizeClassInPreview import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.login.AppQrLoginViewModel import dev.aaa1115910.bv.viewmodel.login.GeetestResult import dev.aaa1115910.bv.viewmodel.login.SmsLoginViewModel import dev.aaa1115910.m3qrcode.MaterialShapeQr import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.json.JSONObject import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun LoginScreen( modifier: Modifier = Modifier, smsLoginViewModel: SmsLoginViewModel = koinViewModel(), appQrLoginViewModel: AppQrLoginViewModel = koinViewModel() ) { val context = LocalContext.current val logger = KotlinLogging.logger { } val scope = rememberCoroutineScope() val keyboardController = LocalSoftwareKeyboardController.current val windowSize = calculateWindowSizeClass(context as Activity) var gt3GeetestUtils: GT3GeetestUtils? by remember { mutableStateOf(null) } val gt3ConfigBean by remember { mutableStateOf(GT3ConfigBean()) } var phone by remember { mutableLongStateOf(0) } val setConfig: (challenge: String, gt: String) -> Unit = { challenge, gt -> gt3GeetestUtils!!.startCustomFlow() gt3ConfigBean.api1Json = JSONObject().apply { put("success", 1) put("gt", gt) put("challenge", challenge) } gt3GeetestUtils!!.getGeetest() } val sendSms: (Long) -> Unit = { phoneNumber -> phone = phoneNumber keyboardController?.hide() scope.launch(Dispatchers.IO) { runCatching { smsLoginViewModel.sendSms(phoneNumber) { challenge: String, gt: String -> scope.launch(Dispatchers.Main) { setConfig(challenge, gt) } } } } } val loginWithSms: (Long, Int) -> Unit = { phoneNumber, code -> phone = phoneNumber keyboardController?.hide() if (smsLoginViewModel.sendSmsState != SendSmsState.Success) { R.string.sms_login_toast_send_sms_first.toast(context) } else { scope.launch(Dispatchers.IO) { runCatching { smsLoginViewModel.loginWithSms(code) { scope.launch(Dispatchers.Main) { R.string.login_success.toast(context) } (context as Activity).finish() } } } } } DisposableEffect(Unit) { gt3GeetestUtils = GT3GeetestUtils(context) gt3ConfigBean.apply { pattern = 1 isCanceledOnTouchOutside = false lang = null timeout = 10000 webviewTimeout = 10000 corners = 24 listener = object : GT3Listener() { override fun onReceiveCaptchaCode(p0: Int) { logger.info { "Geetest - onReceiveCaptchaCode: $p0" } } override fun onStatistics(p0: String?) { logger.info { "Geetest - onStatistics: $p0" } } override fun onClosed(p0: Int) { logger.info { "Geetest - onClosed: $p0" } smsLoginViewModel.clearCaptchaData() } override fun onSuccess(p0: String?) { logger.info { "Geetest - onSuccess: $p0" } } override fun onFailed(p0: GT3ErrorBean?) { logger.info { "Geetest - onFailed: $p0" } smsLoginViewModel.clearCaptchaData() } override fun onButtonClick() { logger.info { "Geetest - onButtonClick" } } override fun onDialogResult(result: String) { logger.info { "Geetest - onDialogResult: $result" } runCatching { val geetestResult = Json.decodeFromString(result) smsLoginViewModel.geetestChallenge = geetestResult.geetestChallenge smsLoginViewModel.geetestValidate = geetestResult.geetestValidate smsLoginViewModel.sendSmsState = SendSmsState.Ready gt3GeetestUtils?.showSuccessDialog() scope.launch(Dispatchers.IO) { smsLoginViewModel.sendSms(phone) { _, _ -> } } }.onFailure { gt3GeetestUtils?.showFailedDialog() } } } } gt3GeetestUtils!!.init(gt3ConfigBean) onDispose { gt3GeetestUtils?.destory() } } LaunchedEffect(Unit) { appQrLoginViewModel.requestQRCode() } LaunchedEffect(appQrLoginViewModel.state) { when (appQrLoginViewModel.state) { QrLoginState.Success -> { R.string.login_success.toast(context) context.finish() } QrLoginState.Expired -> { appQrLoginViewModel.requestQRCode() } else -> {} } } DisposableEffect(Unit) { onDispose { appQrLoginViewModel.cancelCheckLoginResultTimer() } } LoginContent( modifier = modifier, windowSize = windowSize, qrLoginUrl = appQrLoginViewModel.loginUrl, onBack = { context.finish() }, onClearCaptchaData = { smsLoginViewModel.clearCaptchaData() }, onSendSms = sendSms, onLogin = loginWithSms ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginContent( modifier: Modifier = Modifier, windowSize: WindowSizeClass, qrLoginUrl: String, onBack: () -> Unit, onClearCaptchaData: () -> Unit, onSendSms: (Long) -> Unit, onLogin: (phoneNumber: Long, code: Int) -> Unit ) { Scaffold( modifier = modifier, topBar = { LargeTopAppBar( title = { Text(text = stringResource(id = R.string.title_mobile_activity_login)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back" ) } } ) } ) { innerPadding -> when (windowSize.widthSizeClass) { WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> LoginContentCompact( modifier = Modifier.padding(innerPadding), qrLoginUrl = qrLoginUrl, onClearCaptchaData = onClearCaptchaData, onSendSms = onSendSms, onLogin = onLogin ) WindowWidthSizeClass.Expanded -> LoginContentExpanded( modifier = Modifier.padding(innerPadding), qrLoginUrl = qrLoginUrl, onClearCaptchaData = onClearCaptchaData, onSendSms = onSendSms, onLogin = onLogin ) } } } @Composable fun LoginContentCompact( modifier: Modifier = Modifier, qrLoginUrl: String, onClearCaptchaData: () -> Unit, onSendSms: (Long) -> Unit, onLogin: (phoneNumber: Long, code: Int) -> Unit ) { var showQrCode by remember { mutableStateOf(false) } Box( modifier = modifier .fillMaxSize(), contentAlignment = Alignment.TopCenter ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { SmsLoginInputs( modifier = Modifier .padding(24.dp) .widthIn(max = 400.dp), onClearCaptchaData = onClearCaptchaData, onSendSms = onSendSms, onLogin = onLogin ) AnimatedVisibility(showQrCode) { MaterialShapeQr( Modifier .padding(top = 36.dp) .size(240.dp), qrLoginUrl ) } } if (!showQrCode) { TextButton( modifier = Modifier.align(Alignment.BottomCenter), onClick = { showQrCode = true } ) { Text(text = stringResource(dev.aaa1115910.bv.mobile.R.string.qr_login_button_login)) } } } } @Composable fun LoginContentExpanded( modifier: Modifier = Modifier, qrLoginUrl: String, onClearCaptchaData: () -> Unit, onSendSms: (Long) -> Unit, onLogin: (phoneNumber: Long, code: Int) -> Unit ) { Row( modifier = modifier ) { Box( modifier = Modifier .weight(1f) .fillMaxHeight(), contentAlignment = Alignment.Center ) { SmsLoginInputs( modifier = Modifier .width(400.dp), onClearCaptchaData = onClearCaptchaData, onSendSms = onSendSms, onLogin = onLogin ) } VerticalDivider( modifier = Modifier .fillMaxHeight(0.5f) .align(Alignment.CenterVertically) ) Box( modifier = Modifier .weight(1f) .fillMaxHeight(), contentAlignment = Alignment.Center ) { QrLogin( modifier = Modifier, qrLoginUrl = qrLoginUrl ) } } } @Composable fun SmsLoginInputs( modifier: Modifier = Modifier, onClearCaptchaData: () -> Unit, onSendSms: (Long) -> Unit, onLogin: (phoneNumber: Long, code: Int) -> Unit ) { val keyboardController = LocalSoftwareKeyboardController.current var phoneNumberText by remember { mutableStateOf("") } val phoneNumber by remember(phoneNumberText) { mutableLongStateOf(runCatching { phoneNumberText.toLong() }.getOrNull() ?: 0) } var codeText by remember { mutableStateOf("") } val code by remember(codeText) { mutableIntStateOf(runCatching { codeText.toInt() }.getOrNull() ?: 0) } val sendSmsButtonEnabled by remember(phoneNumber) { derivedStateOf { phoneNumber != 0L && phoneNumberText.length == 11 } } val loginButtonEnabled by remember(code) { derivedStateOf { sendSmsButtonEnabled && code != 0 && codeText.length == 6 } } Column( modifier = modifier, horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = phoneNumberText, onValueChange = { phoneNumberText = it onClearCaptchaData() }, label = { Text(text = stringResource(R.string.sms_login_phone_number)) }, maxLines = 1, shape = MaterialTheme.shapes.large, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Phone, imeAction = ImeAction.Send ), keyboardActions = KeyboardActions( onSend = { if (sendSmsButtonEnabled) { onSendSms(phoneNumber) keyboardController?.hide() } } ), trailingIcon = { TextButton( onClick = { onSendSms(phoneNumber) keyboardController?.hide() }, enabled = sendSmsButtonEnabled ) { Text(text = stringResource(R.string.sms_login_button_send_sms)) } } ) OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = codeText, onValueChange = { codeText = it }, label = { Text(text = stringResource(R.string.sms_login_code)) }, maxLines = 1, shape = MaterialTheme.shapes.large, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( onDone = { if (loginButtonEnabled) { onLogin(phoneNumber, code) keyboardController?.hide() } } ) ) Button( modifier = Modifier.fillMaxWidth(), onClick = { onLogin(phoneNumber, code) keyboardController?.hide() }, enabled = loginButtonEnabled ) { Text(text = stringResource(R.string.sms_login_button_login)) } } } @Composable fun QrLogin( modifier: Modifier = Modifier, qrLoginUrl: String ) { Box( modifier = modifier ) { MaterialShapeQr( modifier = Modifier.size(240.dp), content = qrLoginUrl ) } } @Preview @Composable fun SmsLoginInputsPreview() { BVMobileTheme { Surface { SmsLoginInputs( modifier = Modifier.padding(24.dp), onClearCaptchaData = {}, onSendSms = {}, onLogin = { _, _ -> } ) } } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Preview @Preview(device = "spec:width=1280dp,height=800dp,dpi=240") @Preview(device = "spec:width=1280dp,height=800dp,dpi=240,orientation=portrait") @Composable private fun LoginScreenPreview() { val windowSize = calculateWindowSizeClassInPreview() BVMobileTheme { LoginContent( modifier = Modifier, windowSize = windowSize, qrLoginUrl = "https://www.example.com", onBack = {}, onClearCaptchaData = {}, onSendSms = { _ -> }, onLogin = { _, _ -> } ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/MobileMainScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Segment import androidx.compose.material.icons.rounded.FiberNew import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.navigationsuite.NavigationSuite import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldLayout import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraph import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import coil.compose.AsyncImage import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import coil.size.Size import com.origeek.imageViewer.previewer.ImagePreviewer import com.origeek.imageViewer.previewer.VerticalDragType import com.origeek.imageViewer.previewer.rememberPreviewerState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.bv.component.DevelopingTipContent import dev.aaa1115910.bv.mobile.activities.FavoriteActivity import dev.aaa1115910.bv.mobile.activities.FollowingSeasonActivity import dev.aaa1115910.bv.mobile.activities.FollowingUserActivity import dev.aaa1115910.bv.mobile.activities.HistoryActivity import dev.aaa1115910.bv.mobile.activities.LoginActivity import dev.aaa1115910.bv.mobile.activities.SettingsActivity import dev.aaa1115910.bv.mobile.component.home.UserDialog import dev.aaa1115910.bv.mobile.screen.home.DynamicScreen import dev.aaa1115910.bv.mobile.screen.home.HomeScreen import dev.aaa1115910.bv.mobile.screen.home.SearchScreen import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.swapList import dev.aaa1115910.bv.viewmodel.UserSwitchViewModel import dev.aaa1115910.bv.viewmodel.UserViewModel import dev.aaa1115910.bv.viewmodel.home.PopularViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun MobileMainScreen( modifier: Modifier = Modifier, popularViewModel: PopularViewModel = koinViewModel(), userViewModel: UserViewModel = koinViewModel(), userSwitchViewModel: UserSwitchViewModel = koinViewModel() ) { val logger = KotlinLogging.logger("MobileMainScreen") val state = rememberMobileMainScreenState( popularViewModel = popularViewModel, userViewModel = userViewModel, userSwitchViewModel = userSwitchViewModel ) val context = LocalContext.current val scope = rememberCoroutineScope() val windowSizeClass = calculateWindowSizeClass(context as Activity) val navSuiteType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(currentWindowAdaptiveInfo()) val pictures = remember { mutableStateListOf() } val previewerState = rememberPreviewerState( verticalDragType = VerticalDragType.UpAndDown, pageCount = { pictures.size }, getKey = { pictures[it].key } ) val onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit = { newPictures, afterSetPictures -> pictures.swapList(newPictures) logger.fInfo { "update image previewer pictures list: $newPictures" } afterSetPictures() } val verticalNavOrder = listOf( MobileMainScreenNav.Search, MobileMainScreenNav.Home, MobileMainScreenNav.Zone, MobileMainScreenNav.Dynamic ).map { it.name } val horizontalNavOrder = listOf( MobileMainScreenNav.Home, MobileMainScreenNav.Zone, MobileMainScreenNav.Search, MobileMainScreenNav.Dynamic, ).map { it.name } val compareNavIndex: (String?, String?) -> Boolean = { a, b -> if (navSuiteType == NavigationSuiteType.NavigationBar) { horizontalNavOrder.indexOf(a) < horizontalNavOrder.indexOf(b) } else { verticalNavOrder.indexOf(a) < verticalNavOrder.indexOf(b) } } val navEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { val coefficient = 10 if (navSuiteType == NavigationSuiteType.NavigationBar) { if (compareNavIndex( targetState.destination.route, initialState.destination.route ) ) { fadeIn() + slideInHorizontally { -it / coefficient } } else { fadeIn() + slideInHorizontally { it / coefficient } } } else { if (compareNavIndex( targetState.destination.route, initialState.destination.route ) ) { fadeIn() + slideInVertically { -it / coefficient } } else { fadeIn() + slideInVertically { it / coefficient } } } } val navExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { val coefficient = 10 if (navSuiteType == NavigationSuiteType.NavigationBar) { if (compareNavIndex( targetState.destination.route, initialState.destination.route ) ) { fadeOut() + slideOutHorizontally { it / coefficient } } else { fadeOut() + slideOutHorizontally { -it / coefficient } } } else { if (compareNavIndex( targetState.destination.route, initialState.destination.route ) ) { fadeOut() + slideOutVertically { it / coefficient } } else { fadeOut() + slideOutVertically { -it / coefficient } } } } BackHandler(previewerState.canClose || previewerState.animating) { if (previewerState.canClose) scope.launch { previewerState.closeTransform() } } BackHandler(state.showUserDialog) { state.hideUserDialog() } Box( modifier = modifier, ) { NavigationSuiteScaffoldLayout( navigationSuite = { NavigationSuit( mobileMainScreenState = state, navigationSuiteType = navSuiteType, avatar = userViewModel.face, onNavigate = state::navigate, onShowUserDialog = state::showUserDialog ) } ) { NavHost( navController = state.navController, startDestination = MobileMainScreenNav.Home.name, enterTransition = navEnterTransition, exitTransition = navExitTransition ) { composable(MobileMainScreenNav.Home.name) { HomeScreen( rcmdGridState = state.rcmdGridState, popularGridState = state.popularGridState, windowSize = state.windowSizeClass.widthSizeClass, onShowUserDialog = state::showUserDialog ) } composable(MobileMainScreenNav.Dynamic.name) { BackHandler(previewerState.canClose || previewerState.animating) { if (previewerState.canClose) scope.launch { previewerState.closeTransform() } } DynamicScreen( dynamicGridState = state.dynamicGridState, previewerState = previewerState, onShowPreviewer = onShowPreviewer, // dynamicViewModel = dynamicViewModel ) } composable(MobileMainScreenNav.Search.name) { SearchScreen() } composable(MobileMainScreenNav.Zone.name) { DevelopingTipContent() } } } } ImagePreviewer( modifier = Modifier .fillMaxSize(), state = previewerState, imageLoader = { index -> val imageRequest = ImageRequest.Builder(LocalContext.current) .data(pictures[index].url) .size(Size.ORIGINAL) .build() rememberAsyncImagePainter(imageRequest) } ) UserDialog( show = state.showUserDialog, windowWidthSizeClass = windowSizeClass.widthSizeClass, onHideDialog = { state.showUserDialog = false }, currentUser = userSwitchViewModel.currentUser.takeIf { it.id != -1 }, userList = userSwitchViewModel.userDbList, onSwitchUser = { user -> scope.launch(Dispatchers.IO) { userSwitchViewModel.switchUser(user) } }, onAddUser = { context.startActivity(Intent(context, LoginActivity::class.java)) }, onDeleteUser = { user -> scope.launch(Dispatchers.IO) { userSwitchViewModel.deleteUser(user) } }, onOpenFollowingUser = { context.startActivity( Intent(context, FollowingUserActivity::class.java) ) }, onOpenHistory = { context.startActivity( Intent(context, HistoryActivity::class.java) ) }, onOpenFavorite = { context.startActivity( Intent(context, FavoriteActivity::class.java) ) }, onOpenFollowingPgc = { context.startActivity( Intent(context, FollowingSeasonActivity::class.java) ) }, onOpenToView = {}, onOpenSettings = { context.startActivity(Intent(context, SettingsActivity::class.java)) } ) } @Composable private fun NavigationSuit( modifier: Modifier = Modifier, mobileMainScreenState: MobileMainScreenState, navigationSuiteType: NavigationSuiteType, avatar: String, onNavigate: (MobileMainScreenNav) -> Unit, onShowUserDialog: () -> Unit, ) { when (navigationSuiteType) { NavigationSuiteType.NavigationBar -> { NavigationSuite( modifier = modifier ) { listOf( MobileMainScreenNav.Home, MobileMainScreenNav.Zone, MobileMainScreenNav.Search, MobileMainScreenNav.Dynamic, ).forEach { navItem -> item( icon = { Icon(navItem.icon, contentDescription = navItem.displayName) }, label = { Text(navItem.displayName) }, selected = mobileMainScreenState.currentNavItem == navItem, onClick = { onNavigate(navItem) } ) } } } NavigationSuiteType.NavigationRail -> { NavigationRail( modifier = modifier, containerColor = MaterialTheme.colorScheme.surfaceContainer ) { NavigationRailItem( icon = { if (avatar.isBlank()) { Icon(Icons.Rounded.Person, contentDescription = "User Avatar") } else { Box( modifier = Modifier .clip(CircleShape) .background(Color.Gray) ) { AsyncImage( modifier = Modifier .size(36.dp), model = avatar, contentDescription = null, contentScale = ContentScale.Crop ) } } }, selected = false, onClick = onShowUserDialog ) Spacer(Modifier.weight(1f)) listOf( MobileMainScreenNav.Search, MobileMainScreenNav.Home, MobileMainScreenNav.Zone, MobileMainScreenNav.Dynamic, ).forEach { navItem -> NavigationRailItem( icon = { Icon(navItem.icon, contentDescription = navItem.displayName) }, label = { Text(navItem.displayName) }, selected = mobileMainScreenState.currentNavItem == navItem, onClick = { onNavigate(navItem) } ) } Spacer(Modifier.weight(1f)) listOf(MobileMainScreenNav.Setting).forEach { navItem -> NavigationRailItem( icon = { Icon(navItem.icon, contentDescription = navItem.displayName) }, label = { Text(navItem.displayName) }, selected = mobileMainScreenState.currentNavItem == navItem, onClick = { onNavigate(navItem) } ) } } } } } data class MobileMainScreenState( val context: Context, val scope: CoroutineScope, val windowSizeClass: WindowSizeClass, //val drawerState: DrawerState, val rcmdGridState: LazyGridState, val popularGridState: LazyGridState, val dynamicGridState: LazyStaggeredGridState, val navController: NavHostController, val currentBackStackEntry: NavBackStackEntry?, val currentNavItem: MobileMainScreenNav, private val homeViewModel: PopularViewModel, private val userViewModel: UserViewModel, private val userSwitchViewModel: UserSwitchViewModel, ) { companion object { val logger = KotlinLogging.logger {} } var activeSearch by mutableStateOf(false) var showUserDialog by mutableStateOf(false) fun navigate(navItem: MobileMainScreenNav) { logger.fInfo { "Navigate to ${navItem.name}" } val navigateToRoute: () -> Unit = { val route = navItem.name navController.navigate(route) { launchSingleTop = true popUpTo(navController.graph.findStartDestination().id) { inclusive = false saveState = true } restoreState = true } } val notCurrentNavItem = currentNavItem != navItem when (navItem) { MobileMainScreenNav.Home -> { if (notCurrentNavItem) { navigateToRoute() } else { scope.launch { rcmdGridState.animateScrollToItem(0) } scope.launch { popularGridState.animateScrollToItem(0) } } } MobileMainScreenNav.Search -> { if (notCurrentNavItem) { navigateToRoute() } } MobileMainScreenNav.Setting -> { context.startActivity(Intent(context, SettingsActivity::class.java)) } MobileMainScreenNav.Dynamic -> { if (notCurrentNavItem) { navigateToRoute() } else { scope.launch { dynamicGridState.animateScrollToItem(0) } } } MobileMainScreenNav.Zone -> { if (notCurrentNavItem) { navigateToRoute() } } } @SuppressLint("RestrictedApi") val breadcrumb = navController .currentBackStack .value .map { it.destination } .filterNot { it is NavGraph } .joinToString(" > ") { it.route ?: "null" } logger.fInfo { "Navigation Stack: > $breadcrumb" } } fun showUserDialog() { scope.launch(Dispatchers.IO) { userSwitchViewModel.updateUserDbList() this@MobileMainScreenState.showUserDialog = true } } fun hideUserDialog() { this@MobileMainScreenState.showUserDialog = false } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun rememberMobileMainScreenState( context: Context = LocalContext.current, scope: CoroutineScope = rememberCoroutineScope(), windowSizeClass: WindowSizeClass = calculateWindowSizeClass(context as Activity), drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), rcmdGridState: LazyGridState = rememberLazyGridState(), popularGridState: LazyGridState = rememberLazyGridState(), dynamicGridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), navController: NavHostController = rememberNavController(), popularViewModel: PopularViewModel,//= koinNavViewModel(), userViewModel: UserViewModel,//= koinNavViewModel(), userSwitchViewModel: UserSwitchViewModel //= koinNavViewModel() ): MobileMainScreenState { val lifecycleOwner = LocalLifecycleOwner.current val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentNavItem by remember { derivedStateOf { MobileMainScreenNav.fromName(currentBackStackEntry?.destination?.route ?: "") } } LaunchedEffect(Unit) { if (popularViewModel.popularVideoList.isNotEmpty()) { scope.launch(Dispatchers.IO) { popularViewModel.loadMore() } } } LaunchedEffect(Unit) { userViewModel.updateUserInfo() } DisposableEffect(lifecycleOwner) { var leaveFromThisPage = false val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) { leaveFromThisPage = true } else if (event == Lifecycle.Event.ON_RESUME) { if (leaveFromThisPage) { scope.launch(Dispatchers.IO) { userSwitchViewModel.updateUserDbList() } } leaveFromThisPage = false } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } return remember( context, scope, windowSizeClass, drawerState, rcmdGridState, popularGridState, dynamicGridState, navController, currentNavItem ) { MobileMainScreenState( context, scope, windowSizeClass, //drawerState, rcmdGridState, popularGridState, dynamicGridState, navController, currentBackStackEntry, currentNavItem, popularViewModel, userViewModel, userSwitchViewModel ) } } enum class MobileMainScreenNav(val displayName: String, val icon: ImageVector) { Home("首页", Icons.Rounded.Home), Zone("分区", Icons.AutoMirrored.Rounded.Segment), Search("搜索", Icons.Rounded.Search), Dynamic("动态", Icons.Rounded.FiberNew), Setting("设置", Icons.Rounded.Settings), ; companion object { fun fromName(name: String) = entries.firstOrNull { it.name == name } ?: Home } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/QrTokenResultScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import android.content.Intent import android.content.res.Configuration import android.net.Uri import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.entity.AuthData import dev.aaa1115910.bv.entity.BvScheme import dev.aaa1115910.bv.mobile.R import dev.aaa1115910.bv.mobile.component.user.UserAvatar import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.repository.UserRepository import dev.aaa1115910.bv.util.toast import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.compose.koinInject @Composable fun QrTokenResultScreen( modifier: Modifier = Modifier, userRepository: UserRepository = koinInject() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger { } var parsing by remember { mutableStateOf(true) } var authData by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var uid by remember { mutableLongStateOf(-1L) } var username by remember { mutableStateOf("") } var avatar by remember { mutableStateOf("") } var addingUser by remember { mutableStateOf(false) } val onBack: () -> Unit = { (context as Activity).finish() } val onConfirm: () -> Unit = { if (!addingUser && authData != null) { addingUser = true scope.launch(Dispatchers.IO) { runCatching { userRepository.addUser(authData!!) }.onFailure { logger.error(it) { "Failed to save auth data to prefs" } withContext(Dispatchers.Main) { it.message?.toast(context) } }.onSuccess { withContext(Dispatchers.Main) { R.string.qr_token_result_toast_add_success.toast(context) } (context as Activity).finish() context.startActivity( context.packageManager.getLaunchIntentForPackage(context.packageName) ?.apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK } ) } } } } LaunchedEffect(Unit) { runCatching { val uri = (context as Activity).intent.getParcelableExtra("uri") ?: throw IllegalArgumentException("Uri not found in intent extras") val data = BvScheme.QrToken.fromUri(uri) ?: throw IllegalArgumentException("Invalid QR token URI: $uri") val qrToken = data as BvScheme.QrToken authData = AuthData.fromJson(qrToken.auth) uid = qrToken.uid username = qrToken.username avatar = qrToken.avatar }.onFailure { logger.warn(it) { "Failed to parse QR token result" } error = it } parsing = false } QrTokenResultContent( modifier = modifier, authData = authData, uid = uid, username = username, avatar = avatar, parsing = parsing, error = error, onBack = onBack, onConfirm = onConfirm ) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun QrTokenResultContent( modifier: Modifier = Modifier, authData: AuthData?, uid: Long, username: String, avatar: String, parsing: Boolean, error: Throwable?, onBack: () -> Unit, onConfirm: () -> Unit ) { Scaffold( modifier = modifier, topBar = { LargeTopAppBar( title = { Text(text = stringResource(R.string.title_mobile_activity_qr_token_result)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null ) } } ) } ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize() ) { if (parsing) { LoadingIndicator( modifier = Modifier .align(Alignment.Center) .size(80.dp) ) } else if (error != null) { Box { Column( modifier = Modifier .verticalScroll(rememberScrollState()) ) { Text(text = error.stackTraceToString()) } } } else if (authData != null) { Box( modifier = Modifier.fillMaxSize() ) { Column( modifier = Modifier .padding(top = 24.dp) .align(Alignment.TopCenter), horizontalAlignment = Alignment.CenterHorizontally ) { UserAvatar( modifier = Modifier.padding(vertical = 24.dp), avatar = avatar ) Text( text = username, style = MaterialTheme.typography.titleMedium ) Text( text = "$uid", style = MaterialTheme.typography.bodySmall, ) } FilledTonalButton( modifier = Modifier .align(Alignment.BottomCenter) .padding(24.dp) .fillMaxWidth(), onClick = onConfirm ) { Text(text = stringResource(R.string.qr_token_result_button_add_user)) } } } else { Text("unknown error") } } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun QrTokenResultContentParsingPreview() { BVMobileTheme { QrTokenResultContent( authData = null, uid = -1L, username = "", avatar = "", parsing = true, error = null, onBack = {}, onConfirm = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun QrTokenResultContentPreview() { BVMobileTheme { QrTokenResultContent( authData = AuthData( uid = 123456789L, uidCkMd5 = "exampleUidCkMd5", sid = "exampleSid", sessData = "exampleSessData", biliJct = "exampleBiliJct", tokenExpiredData = 1728000000L, // Example timestamp accessToken = "exampleAccessToken", refreshToken = "exampleRefreshToken" ), uid = 3252351L, username = "bishi", avatar = "", parsing = false, error = null, onBack = {}, onConfirm = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun QrTokenResultContentErrorPreview() { BVMobileTheme { QrTokenResultContent( authData = AuthData( uid = 123456789L, uidCkMd5 = "exampleUidCkMd5", sid = "exampleSid", sessData = "exampleSessData", biliJct = "exampleBiliJct", tokenExpiredData = 1728000000L, // Example timestamp accessToken = "exampleAccessToken", refreshToken = "exampleRefreshToken" ), uid = -1L, username = "", avatar = "", parsing = false, error = IllegalStateException("An error occurred while parsing the QR token result"), onBack = {}, onConfirm = {} ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/RegionBlockScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import kotlin.system.exitProcess @Composable fun RegionBlockScreen(modifier: Modifier = Modifier) { val context = LocalContext.current val exitApp: () -> Unit = { (context as Activity).finish() exitProcess(0) } RegionBlockContent( modifier = modifier, onExit = exitApp ) } @Composable private fun RegionBlockContent( modifier: Modifier = Modifier, onExit: () -> Unit ) { Scaffold( modifier = modifier ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize() ) { Column( modifier = Modifier .padding(horizontal = 24.dp) .align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp) ) { Icon( modifier = Modifier.size(32.dp), imageVector = Icons.Default.WarningAmber, contentDescription = null, tint = MaterialTheme.colorScheme.error ) Text( text = stringResource(R.string.region_block_title), style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center ) Text( text = stringResource(R.string.region_block_subtitle_mobile), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) } FilledTonalButton( modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 24.dp, vertical = 12.dp) .widthIn(max = 320.dp) .fillMaxWidth(), onClick = onExit ) { Text(text = stringResource(R.string.region_block_exit_button)) } } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun RegionBlockScreenPreview() { BVMobileTheme { RegionBlockContent( onExit = {} ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/UserSpaceScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.app.Activity import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import dev.aaa1115910.bv.mobile.activities.VideoPlayerActivity import dev.aaa1115910.bv.mobile.component.videocard.UpSpaceVideoItem import dev.aaa1115910.bv.viewmodel.user.UserSpaceViewModel import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun UserSpaceScreen( modifier: Modifier = Modifier, userSpaceViewModel: UserSpaceViewModel = koinViewModel() ) { val context = LocalContext.current val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) LaunchedEffect(Unit) { val intent = (context as Activity).intent if (intent.hasExtra("mid")) { val mid = intent.getLongExtra("mid", 0) val name = intent.getStringExtra("name") ?: "" userSpaceViewModel.upMid = mid userSpaceViewModel.upName = name userSpaceViewModel.update() } else { context.finish() } } Scaffold( modifier = modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(text = userSpaceViewModel.upName) }, navigationIcon = { IconButton(onClick = { (context as Activity).finish() }) { Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null) } }, scrollBehavior = scrollBehavior ) } ) { innerPadding -> LazyColumn( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()) ) { items(items = userSpaceViewModel.spaceVideos) { video -> UpSpaceVideoItem( spaceVideo = video, onClick = { VideoPlayerActivity.actionStart( context = context, aid = video.aid ) } ) } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/VideoPlayerScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen import android.annotation.SuppressLint import android.app.Activity import android.content.pm.ActivityInfo import android.view.WindowManager import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil.compose.AsyncImage import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.origeek.imageViewer.previewer.ImagePreviewer import com.origeek.imageViewer.previewer.ImagePreviewerState import com.origeek.imageViewer.previewer.VerticalDragType import com.origeek.imageViewer.previewer.rememberPreviewerState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.CommentSort import dev.aaa1115910.bv.R import dev.aaa1115910.bv.mobile.activities.VideoPlayerActivity import dev.aaa1115910.bv.mobile.component.player.VideoPlayerPages import dev.aaa1115910.bv.mobile.component.reply.CommentItem import dev.aaa1115910.bv.mobile.component.reply.ReplySheetScaffold import dev.aaa1115910.bv.mobile.component.videocard.RelatedVideoItem import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.player.entity.LocalVideoPlayerConfigData import dev.aaa1115910.bv.player.entity.LocalVideoPlayerDanmakuMasksData import dev.aaa1115910.bv.player.entity.LocalVideoPlayerHistoryData import dev.aaa1115910.bv.player.entity.LocalVideoPlayerLoadStateData import dev.aaa1115910.bv.player.entity.LocalVideoPlayerLogsData import dev.aaa1115910.bv.player.entity.LocalVideoPlayerPaymentData import dev.aaa1115910.bv.player.entity.LocalVideoPlayerSeekThumbData import dev.aaa1115910.bv.player.entity.LocalVideoPlayerVideoInfoData import dev.aaa1115910.bv.player.entity.LocalVideoPlayerVideoShotData import dev.aaa1115910.bv.player.entity.VideoListPart import dev.aaa1115910.bv.player.entity.VideoListPgcEpisode import dev.aaa1115910.bv.player.entity.VideoListUgcEpisode import dev.aaa1115910.bv.player.entity.VideoPlayerConfigData import dev.aaa1115910.bv.player.entity.VideoPlayerDanmakuMasksData import dev.aaa1115910.bv.player.entity.VideoPlayerHistoryData import dev.aaa1115910.bv.player.entity.VideoPlayerLoadStateData import dev.aaa1115910.bv.player.entity.VideoPlayerLogsData import dev.aaa1115910.bv.player.entity.VideoPlayerPaymentData import dev.aaa1115910.bv.player.entity.VideoPlayerSeekThumbData import dev.aaa1115910.bv.player.entity.VideoPlayerVideoInfoData import dev.aaa1115910.bv.player.entity.VideoPlayerVideoShotData import dev.aaa1115910.bv.player.mobile.BvPlayer import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.formatPubTimeString import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.swapList import dev.aaa1115910.bv.viewmodel.CommentViewModel import dev.aaa1115910.bv.viewmodel.SeasonViewModel import dev.aaa1115910.bv.viewmodel.VideoPlayerV3ViewModel import dev.aaa1115910.bv.viewmodel.video.VideoDetailViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoPlayerScreen( playerViewModel: VideoPlayerV3ViewModel = koinViewModel(), commentVideModel: CommentViewModel = koinViewModel(), seasonVideModel: SeasonViewModel = koinViewModel(), videoDetailViewModel: VideoDetailViewModel = koinViewModel(), windowSizeClass: WindowSizeClass ) { val context = LocalContext.current val scope = rememberCoroutineScope() val systemUiController = rememberSystemUiController() val logger = KotlinLogging.logger("VideoPlayerScreen") var isVideoFullscreen by rememberSaveable { mutableStateOf(false) } val forcePortrait = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact || windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact val pictures = remember { mutableStateListOf() } val previewerState = rememberPreviewerState( verticalDragType = VerticalDragType.UpAndDown, pageCount = { pictures.size }, getKey = { pictures[it].key } ) val replySheetState = rememberBottomSheetScaffoldState() val setPreviewerPictures: (List, () -> Unit) -> Unit = { newPictures, afterSetPictures -> pictures.clear() pictures.addAll(newPictures) afterSetPictures() } SideEffect { systemUiController.isStatusBarVisible = !isVideoFullscreen systemUiController.isNavigationBarVisible = !isVideoFullscreen if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded) { systemUiController.statusBarDarkContentEnabled = true systemUiController.setStatusBarColor(Color.Black) } if (forcePortrait) { if (isVideoFullscreen) { (context as Activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } else { //在模拟器设为手机尺寸时,横屏时会莫名其妙抛出异常,貌似与折叠屏特性有关,因此手机上强制竖屏 //java.lang.IllegalArgumentException: Bounding rectangle must start at the top or left window edge for folding features @SuppressLint("SourceLockedOrientationActivity") (context as Activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT } } else { (context as Activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } } LaunchedEffect(isVideoFullscreen) { if (isVideoFullscreen) { (context as Activity).window.setFlags( WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN ) } else { (context as Activity).window.clearFlags( WindowManager.LayoutParams.FLAG_FULLSCREEN ) } } BackHandler(previewerState.canClose || previewerState.animating) { if (previewerState.canClose) scope.launch { previewerState.closeTransform() } } BackHandler(isVideoFullscreen) { isVideoFullscreen = false } Scaffold( containerColor = if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded) Color.Black else MaterialTheme.colorScheme.surfaceContainer ) { innerPadding -> Row( modifier = Modifier .ifElse( !isVideoFullscreen, Modifier.padding(top = innerPadding.calculateTopPadding()) ) //.padding(top = innerPadding.calculateTopPadding()) ) { val leftPartWidth by animateFloatAsState( targetValue = if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded && !isVideoFullscreen) 0.6f else 1f, label = "VideoPlayerLeftPartWidth" ) Column( modifier = Modifier .fillMaxWidth(leftPartWidth) ) { if (playerViewModel.videoPlayer != null) { CompositionLocalProvider( LocalVideoPlayerSeekThumbData provides VideoPlayerSeekThumbData( idleIcon = playerViewModel.playerIconIdle, movingIcon = playerViewModel.playerIconMoving ), LocalVideoPlayerVideoInfoData provides VideoPlayerVideoInfoData( width = playerViewModel.currentVideoWidth, height = playerViewModel.currentVideoHeight, codec = playerViewModel.currentVideoCodec.name, title = playerViewModel.title, partTitle = playerViewModel.partTitle, ), LocalVideoPlayerLogsData provides VideoPlayerLogsData( logs = playerViewModel.logs ), LocalVideoPlayerHistoryData provides VideoPlayerHistoryData( lastPlayed = playerViewModel.lastPlayed, ), LocalVideoPlayerPaymentData provides VideoPlayerPaymentData( needPay = playerViewModel.needPay, epid = playerViewModel.epid, ), LocalVideoPlayerLoadStateData provides VideoPlayerLoadStateData( loadState = playerViewModel.loadState, errorMessage = playerViewModel.errorMessage, ), LocalVideoPlayerConfigData provides VideoPlayerConfigData( availableResolutions = playerViewModel.availableQuality, availableVideoCodec = playerViewModel.availableVideoCodec, availableAudio = playerViewModel.availableAudio, availableSubtitleTracks = playerViewModel.availableSubtitle, availableVideoList = playerViewModel.availableVideoList, currentVideoCid = playerViewModel.currentCid, currentResolution = playerViewModel.currentQuality, currentVideoCodec = playerViewModel.currentVideoCodec, currentVideoAspectRatio = playerViewModel.currentVideoAspectRatio, currentVideoSpeed = playerViewModel.currentPlaySpeed, currentAudio = playerViewModel.currentAudio, currentDanmakuEnabled = playerViewModel.currentDanmakuEnabled, currentDanmakuEnabledList = playerViewModel.currentDanmakuTypes, currentDanmakuScale = playerViewModel.currentDanmakuScale, currentDanmakuOpacity = playerViewModel.currentDanmakuOpacity, currentDanmakuArea = playerViewModel.currentDanmakuArea, currentDanmakuMask = playerViewModel.currentDanmakuMask, currentSubtitleId = playerViewModel.currentSubtitleId, currentSubtitleData = playerViewModel.currentSubtitleData, currentSubtitleFontSize = playerViewModel.currentSubtitleFontSize, currentSubtitleBackgroundOpacity = playerViewModel.currentSubtitleBackgroundOpacity, currentSubtitleBottomPadding = playerViewModel.currentSubtitleBottomPadding, currentPlayMode = playerViewModel.currentPlayMode, incognitoMode = Prefs.incognitoMode, ), LocalVideoPlayerDanmakuMasksData provides VideoPlayerDanmakuMasksData( danmakuMasks = playerViewModel.danmakuMasks, ), LocalVideoPlayerVideoShotData provides VideoPlayerVideoShotData( videoShot = playerViewModel.videoShot, ), ) { BvPlayer( modifier = if (isVideoFullscreen) Modifier .fillMaxSize() .zIndex(1f) else Modifier .ifElse( { windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded }, Modifier .padding(12.dp, 0.dp, 12.dp, 12.dp) .clip(MaterialTheme.shapes.large) ) .fillMaxWidth() .aspectRatio(16f / 9f), isFullScreen = isVideoFullscreen, videoPlayer = playerViewModel.videoPlayer!!, danmakuPlayer = playerViewModel.danmakuPlayer, onClearBackToHistoryData = { playerViewModel.lastPlayed = 0 }, onEnterFullScreen = { isVideoFullscreen = true }, onExitFullScreen = { isVideoFullscreen = false }, onBack = { (context as Activity).finish() }, onChangeResolution = { code, afterChange -> scope.launch(Dispatchers.IO) { playerViewModel.currentQuality = code playerViewModel.playQuality(code) afterChange() } }, onChangeVideoCodec = { codec, afterChange -> scope.launch(Dispatchers.IO) { playerViewModel.currentVideoCodec = codec playerViewModel.playQuality(codec = codec) afterChange() } }, onChangeAudio = { audio, afterChange -> scope.launch(Dispatchers.IO) { playerViewModel.currentAudio = audio playerViewModel.playQuality(audio = audio) afterChange() } }, onChangeSpeed = { speed -> playerViewModel.currentPlaySpeed = speed Prefs.defaultPlaySpeed = speed }, onToggleDanmaku = { enabled -> playerViewModel.currentDanmakuEnabled = enabled Prefs.defaultDanmakuEnabled = enabled }, onEnabledDanmakuTypesChange = { types -> playerViewModel.currentDanmakuTypes.swapList(types) }, onDanmakuOpacityChange = { opacity -> playerViewModel.currentDanmakuOpacity = opacity Prefs.defaultDanmakuOpacity = opacity }, onDanmakuScaleChange = { scale -> playerViewModel.currentDanmakuScale = scale Prefs.defaultDanmakuScale = scale }, onDanmakuAreaChange = { area -> playerViewModel.currentDanmakuArea = area Prefs.defaultDanmakuArea = area }, onPlayModeChange = { playMode -> playerViewModel.currentPlayMode = playMode Prefs.defaultPlayMode = playMode }, onLoadNextVideo = playerViewModel::playNextVideo, onLoadNewVideo = { videoListItem -> logger.fInfo { "on load new video: $videoListItem" } var aid = 0L var cid = 0L var epid: Int? = null var seasonId: Int? = null when (videoListItem) { is VideoListPart -> { aid = videoListItem.aid cid = videoListItem.cid epid = videoListItem.epid seasonId = videoListItem.seasonId } is VideoListUgcEpisode -> { aid = videoListItem.aid cid = videoListItem.cid epid = videoListItem.epid seasonId = videoListItem.seasonId } is VideoListPgcEpisode -> { aid = videoListItem.aid cid = videoListItem.cid epid = videoListItem.epid seasonId = videoListItem.seasonId } } playerViewModel.loadPlayUrl( avid = aid, cid = cid, epid = epid, seasonId = seasonId, continuePlayNext = true ) } ) } } val titles = listOf("简介", "评论") val pagerState = rememberPagerState( initialPage = 0, initialPageOffsetFraction = 0f, pageCount = { 2 } ) if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded) { // 小屏幕下的视频详情推荐/评论 ReplySheetScaffold( aid = commentVideModel.commentId, rpid = commentVideModel.rpid, repliesCount = commentVideModel.rpCount, sheetState = replySheetState, previewerState = previewerState, onShowPreviewer = setPreviewerPictures ) { Column { TabRow( selectedTabIndex = pagerState.currentPage ) { titles.forEachIndexed { index, title -> Tab( selected = pagerState.currentPage == index, onClick = { scope.launch { pagerState.scrollToPage(index) } }, text = { Text( text = title, maxLines = 2, overflow = TextOverflow.Ellipsis ) } ) } } HorizontalPager( state = pagerState ) { page -> when (page) { 0 -> { LazyColumn( modifier = Modifier.fillMaxSize() ) { item { VideoPlayerInfo( modifier = Modifier.padding(12.dp), upAvatar = videoDetailViewModel.videoDetail?.author?.face ?: "", upName = videoDetailViewModel.videoDetail?.author?.name ?: "", upFansCount = 0, title = videoDetailViewModel.videoDetail?.title ?: "", description = videoDetailViewModel.videoDetail?.description ?: "", playCount = videoDetailViewModel.videoDetail?.stat?.view ?: 0, danmakuCount = videoDetailViewModel.videoDetail?.stat?.danmaku ?: 0, date = videoDetailViewModel.videoDetail?.publishDate ?.formatPubTimeString(context) ?: "", avid = videoDetailViewModel.videoDetail?.aid ?: 0 ) } item { VideoPlayerPages( currentCid = playerViewModel.currentCid, pages = videoDetailViewModel.videoDetail?.pages ?: emptyList(), ugcSeason = videoDetailViewModel.videoDetail?.ugcSeason, pgcSections = seasonVideModel.seasonData?.sections ?: emptyList(), onClickPage = { videoPage -> playerViewModel.loadPlayUrl( avid = videoDetailViewModel.videoDetail!!.aid, cid = videoPage.cid, continuePlayNext = true ) }, onClickEpisode = { sectionIndex, episode -> videoDetailViewModel.updateUgcSeasonSectionVideoList( sectionIndex ) playerViewModel.loadPlayUrl( avid = episode.aid, cid = episode.cid, epid = episode.epid, continuePlayNext = true ) } ) } items( items = videoDetailViewModel.videoDetail?.relatedVideos ?: emptyList() ) { relatedVideo -> RelatedVideoItem( relatedVideo = relatedVideo, onClick = { VideoPlayerActivity.actionStart( context = context, aid = relatedVideo.aid, fromSeason = relatedVideo.jumpToSeason ) } ) } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } 1 -> { VideoComments( previewerState = previewerState, comments = commentVideModel.comments, commentSort = commentVideModel.commentSort, refreshingComments = commentVideModel.refreshingComments, onLoadMoreComments = { scope.launch(Dispatchers.IO) { commentVideModel.loadMoreComment() } }, onRefreshComments = { scope.launch(Dispatchers.IO) { commentVideModel.refreshComments() } }, onSwitchCommentSort = { scope.launch(Dispatchers.IO) { commentVideModel.switchCommentSort(it) } }, onShowPreviewer = setPreviewerPictures, onShowReplies = { rpId, repliesCount -> //logger.info { "show reply sheet: rpid=$replyId" } commentVideModel.rpid = rpId commentVideModel.rpCount = repliesCount scope.launch { replySheetState.bottomSheetState.expand() } } ) } } } } } } else { // 大屏幕下视频下方的视频详情和推荐视频 LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 12.dp) ) { item { VideoPlayerInfo( modifier = Modifier.padding(12.dp), upAvatar = videoDetailViewModel.videoDetail?.author?.face ?: "", upName = videoDetailViewModel.videoDetail?.author?.name ?: "", upFansCount = 0, title = videoDetailViewModel.videoDetail?.title ?: "", description = videoDetailViewModel.videoDetail?.description ?: "", playCount = videoDetailViewModel.videoDetail?.stat?.view ?: 0, danmakuCount = videoDetailViewModel.videoDetail?.stat?.danmaku ?: 0, date = videoDetailViewModel.videoDetail?.publishDate ?.formatPubTimeString(context) ?: "", avid = videoDetailViewModel.videoDetail?.aid ?: 0, backgroundColor = MaterialTheme.colorScheme.surfaceContainer ) } item { VideoPlayerPages( modifier = Modifier .padding(vertical = 12.dp) .clip(MaterialTheme.shapes.large), currentCid = playerViewModel.currentCid, pages = videoDetailViewModel.videoDetail?.pages ?: emptyList(), ugcSeason = videoDetailViewModel.videoDetail?.ugcSeason, pgcSections = seasonVideModel.seasonData?.sections ?: emptyList(), onClickPage = { videoPage -> playerViewModel.loadPlayUrl( avid = videoDetailViewModel.videoDetail!!.aid, cid = videoPage.cid, continuePlayNext = true ) }, onClickEpisode = { sectionIndex, episode -> videoDetailViewModel.updateUgcSeasonSectionVideoList( sectionIndex ) playerViewModel.loadPlayUrl( avid = episode.aid, cid = episode.cid, epid = episode.epid, continuePlayNext = true ) } ) } itemsIndexed( items = videoDetailViewModel.videoDetail?.relatedVideos ?: emptyList() ) { index, relatedVideo -> RelatedVideoItem( modifier = Modifier .ifElse( { index == 0 }, Modifier.clip( MaterialTheme.shapes.large.copy( bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp) ) ) ) .ifElse( { index == (videoDetailViewModel.videoDetail?.relatedVideos?.size ?: 0) - 1 }, Modifier.clip( MaterialTheme.shapes.large.copy( topStart = CornerSize(0.dp), topEnd = CornerSize(0.dp) ) ) ), relatedVideo = relatedVideo, onClick = { VideoPlayerActivity.actionStart( context = context, aid = relatedVideo.aid, fromSeason = relatedVideo.jumpToSeason ) } ) } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } } } if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded) { // 大屏幕下的右侧评论 Box( modifier = Modifier.padding(end = 12.dp) ) { ReplySheetScaffold( modifier = Modifier, aid = commentVideModel.commentId, rpid = commentVideModel.rpid, repliesCount = commentVideModel.rpCount, sheetState = replySheetState, previewerState = previewerState, onShowPreviewer = setPreviewerPictures ) { VideoComments( modifier = Modifier.fillMaxWidth(), previewerState = previewerState, comments = commentVideModel.comments, commentSort = commentVideModel.commentSort, refreshingComments = commentVideModel.refreshingComments, onLoadMoreComments = { scope.launch(Dispatchers.IO) { commentVideModel.loadMoreComment() } }, onRefreshComments = { scope.launch(Dispatchers.IO) { commentVideModel.refreshComments() } }, onSwitchCommentSort = { scope.launch(Dispatchers.IO) { commentVideModel.switchCommentSort(it) } }, onShowPreviewer = setPreviewerPictures, onShowReplies = { rpId, repliesCount -> //logger.info { "show reply sheet: rpid=$replyId" } commentVideModel.rpid = rpId commentVideModel.rpCount = repliesCount scope.launch { replySheetState.bottomSheetState.expand() } } ) } } } } } ImagePreviewer( modifier = Modifier .fillMaxSize(), state = previewerState, imageLoader = { index -> val imageRequest = ImageRequest.Builder(LocalContext.current) .data(pictures[index].url) .size(coil.size.Size.ORIGINAL) .build() // 获取图片的初始大小 rememberAsyncImagePainter(imageRequest) //rememberAsyncImagePainter(pictures[index].url) } ) } @Composable fun VideoPlayerInfo( modifier: Modifier = Modifier, upAvatar: String, upName: String, upFansCount: Int, title: String, description: String, playCount: Int, danmakuCount: Int, date: String, avid: Long, backgroundColor: Color = MaterialTheme.colorScheme.surface ) { val summaryTextStyle = MaterialTheme.typography.bodySmall.copy( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) Column( modifier = modifier .fillMaxWidth() .background(backgroundColor), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Row( modifier = Modifier.height(64.dp), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .padding(0.dp, 8.dp, 8.dp, 8.dp) ) { AsyncImage( modifier = Modifier .size(48.dp) .clip(CircleShape) .background(Color.Gray), model = upAvatar, contentDescription = null ) } Column( modifier = Modifier .fillMaxHeight() .padding(vertical = 12.dp), verticalArrangement = Arrangement.SpaceEvenly ) { Text( text = upName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelLarge ) Text( text = "$upFansCount", style = summaryTextStyle, fontSize = 10.sp ) } } Button(onClick = { /*TODO*/ }) { Text(text = "Follow") } } Text( text = title, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium ) ProvideTextStyle(summaryTextStyle) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) Text(text = "$playCount") } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) Text(text = "$danmakuCount") } Text(text = date) Text(text = "av$avid") } Text(text = description) } } } @OptIn(ExperimentalMaterialApi::class) @Composable fun VideoComments( modifier: Modifier = Modifier, previewerState: ImagePreviewerState, comments: List, commentSort: CommentSort, refreshingComments: Boolean, onLoadMoreComments: () -> Unit, onRefreshComments: () -> Unit, onSwitchCommentSort: (CommentSort) -> Unit, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit, onShowReplies: (rpId: Long, repliesCount: Int) -> Unit ) { val listState = rememberLazyListState() val pullRefreshState = rememberPullRefreshState(refreshingComments, { onRefreshComments() }) val shouldLoadMore by remember { derivedStateOf { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 10 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) onLoadMoreComments() } Box( modifier = modifier .fillMaxSize() .pullRefresh(state = pullRefreshState) ) { LazyColumn( state = listState ) { item { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = when (commentSort) { CommentSort.Hot -> "热门评论" CommentSort.Time -> "最新评论" else -> "" }, style = MaterialTheme.typography.titleMedium ) TextButton(onClick = { onSwitchCommentSort( when (commentSort) { CommentSort.Hot -> CommentSort.Time CommentSort.Time -> CommentSort.Hot else -> CommentSort.Hot } ) }) { Text( text = when (commentSort) { CommentSort.Hot -> "按热度" CommentSort.Time -> "按时间" else -> "" } ) } } } itemsIndexed(items = comments) { index, comment -> Box { CommentItem( comment = comment, previewerState = previewerState, onShowPreviewer = onShowPreviewer, onShowReply = { rpId -> onShowReplies(rpId, comment.repliesCount) } ) } } item { Spacer(modifier = Modifier.navigationBarsPadding()) } } PullRefreshIndicator( refreshingComments, pullRefreshState, Modifier.align(Alignment.TopCenter) ) } } @Preview @Composable private fun VideoPlayerInfoPreview() { BVMobileTheme { Surface { VideoPlayerInfo( modifier = Modifier.padding(24.dp), upAvatar = "https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg@450w_450h_progressive.webp", upName = "bishi", upFansCount = 1400000000, title = "This is the video title... repeat, this is the video title.", description = "descriptions....descriptions....descriptions....descriptions....descriptions....descriptions....descriptions....descriptions....descriptions....", playCount = 2434, danmakuCount = 14, date = "2023-5-22 23:17", avid = 170001, ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/home/DynamicScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen.home import android.app.Activity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.origeek.imageViewer.previewer.ImagePreviewerState import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.user.DynamicItem import dev.aaa1115910.biliapi.entity.user.DynamicType import dev.aaa1115910.bv.mobile.activities.DynamicDetailActivity import dev.aaa1115910.bv.mobile.activities.VideoPlayerActivity import dev.aaa1115910.bv.mobile.component.home.dynamic.DynamicItem import dev.aaa1115910.bv.util.OnBottomReached import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.getLane import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.home.DynamicViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun DynamicScreen( modifier: Modifier = Modifier, dynamicViewModel: DynamicViewModel = koinViewModel(), dynamicGridState: LazyStaggeredGridState, previewerState: ImagePreviewerState, onShowPreviewer: (newPictures: List, afterSetPictures: () -> Unit) -> Unit ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger { } val windowSize = calculateWindowSizeClass(context as Activity).widthSizeClass val lane by remember { derivedStateOf { dynamicGridState.getLane() } } val onClickDynamicItem: (DynamicItem) -> Unit = { dynamicItem -> logger.fInfo { "click dynamic type: ${dynamicItem.type}" } when (dynamicItem.type) { DynamicType.Av -> { println("=== ${dynamicItem.video} ===") VideoPlayerActivity.actionStart( context = context, aid = dynamicItem.video!!.aid, fromSeason = dynamicItem.video!!.seasonId != null && dynamicItem.video!!.seasonId != 0, ) } DynamicType.Pgc -> { VideoPlayerActivity.actionStart( context = context, //aid = dynamicItem.pgc!!.epid, aid = 0, fromSeason = true, epid = dynamicItem.pgc!!.epid, seasonId = dynamicItem.pgc!!.seasonId, ) } else -> { if (dynamicItem.id != null) { DynamicDetailActivity.actionStart(context, dynamicItem.id!!) } else { "原动态不存在".toast(context) } } } } dynamicGridState.OnBottomReached( loading = dynamicViewModel.loadingAll ) { logger.fInfo { "on reached rcmd page bottom" } scope.launch(Dispatchers.IO) { dynamicViewModel.loadMoreAll() } } Scaffold( modifier = modifier, topBar = { TopAppBar( title = { Text(text = "Dynamic") }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ) ) }, containerColor = MaterialTheme.colorScheme.surfaceContainer ) { innerPadding -> Box( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()) ) { LazyVerticalStaggeredGrid( modifier = modifier .fillMaxSize() .ifElse( { windowSize != WindowWidthSizeClass.Compact }, Modifier.clip(MaterialTheme.shapes.large) ) .background(MaterialTheme.colorScheme.surface), columns = StaggeredGridCells.Adaptive(300.dp), state = dynamicGridState, verticalItemSpacing = 8.dp, horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(if (lane == 1) 0.dp else 8.dp) ) { items(items = dynamicViewModel.dynamicAllList) { dynamicItem -> DynamicItem( modifier = Modifier .ifElse(lane != 1, Modifier.clip(MaterialTheme.shapes.medium)), dynamicItem = dynamicItem, previewerState = previewerState, onShowPreviewer = onShowPreviewer, onClick = onClickDynamicItem ) } } } } } private val exampleAuthorData = DynamicItem.DynamicAuthorModule( author = "author", avatar = "", mid = 0, pubTime = "54 分钟前 投稿了视频", pubAction = "" ) private val exampleFooterData = DynamicItem.DynamicFooterModule( like = 2, comment = 61, share = 8, ) private val exampleVideoData = DynamicItem.DynamicVideoModule( aid = 0, title = "title", cover = "", duration = "23:45", play = "xx play", danmaku = "xx dm", seasonId = 0, cid = 0, text = "desc" ) private val exampleDynamicItemData = DynamicItem( type = DynamicType.Av, author = exampleAuthorData, video = exampleVideoData, footer = exampleFooterData ) ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/home/HomeScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Person import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import coil.compose.AsyncImage import dev.aaa1115910.bv.mobile.activities.VideoPlayerActivity import dev.aaa1115910.bv.mobile.component.home.HomeTab import dev.aaa1115910.bv.mobile.screen.home.home.PopularPage import dev.aaa1115910.bv.mobile.screen.home.home.RcmdPage import dev.aaa1115910.bv.viewmodel.UserViewModel import dev.aaa1115910.bv.viewmodel.home.PopularViewModel import dev.aaa1115910.bv.viewmodel.home.RecommendViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import kotlin.Int @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun HomeScreen( modifier: Modifier = Modifier, rcmdGridState: LazyGridState, popularGridState: LazyGridState, popularViewModel: PopularViewModel = koinViewModel(), recommendViewModel: RecommendViewModel = koinViewModel(), userViewModel: UserViewModel = koinViewModel(), windowSize: WindowWidthSizeClass, onShowUserDialog: () -> Unit ) { val scope = rememberCoroutineScope() val pageState = rememberPagerState(pageCount = { 2 }) Scaffold( modifier = modifier, containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { HomeTopAppBar( windowSize = windowSize, avatar = userViewModel.face, onShowUserDialog = onShowUserDialog ) } ) { innerPadding -> HomeScreenContent( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), pageState = pageState, selectedTabIndex = pageState.currentPage, windowSize = windowSize, rcmdGridState = rcmdGridState, popularGridState = popularGridState, onChangeTabIndex = { scope.launch { pageState.animateScrollToPage(it) } }, popularViewModel = popularViewModel, recommendViewModel = recommendViewModel, ) } } @Composable fun HomeScreenContent( modifier: Modifier = Modifier, pageState: PagerState, selectedTabIndex: Int, windowSize: WindowWidthSizeClass, rcmdGridState: LazyGridState, popularGridState: LazyGridState, onChangeTabIndex: (Int) -> Unit, popularViewModel: PopularViewModel = koinViewModel(), recommendViewModel: RecommendViewModel = koinViewModel(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() Column( modifier = modifier .background(MaterialTheme.colorScheme.surfaceContainer), ) { TabRow( modifier = Modifier .zIndex(1f), selectedTabIndex = selectedTabIndex, containerColor = MaterialTheme.colorScheme.surfaceContainer ) { HomeTab.entries.forEachIndexed { index, tab -> Tab( selected = selectedTabIndex == index, onClick = { onChangeTabIndex(index) }, text = { Text( text = tab.getDisplayName(context), maxLines = 1, overflow = TextOverflow.Ellipsis ) } ) } } Surface( color = MaterialTheme.colorScheme.surface, shape = if (windowSize == WindowWidthSizeClass.Compact) RoundedCornerShape(0.dp) else MaterialTheme.shapes.large, ) { HorizontalPager( modifier = Modifier, state = pageState, ) { page -> when (page) { 0 -> { RcmdPage( state = rcmdGridState, windowSize = windowSize, videos = recommendViewModel.recommendVideoList, onClickVideo = { aid -> VideoPlayerActivity.actionStart(context = context, aid = aid) }, loading = recommendViewModel.loading, refreshing = recommendViewModel.refreshing, onRefresh = { scope.launch(Dispatchers.IO) { recommendViewModel.resetPage() //避免刷新太快 delay(300) recommendViewModel.loadMore { //clear data before set new data recommendViewModel.clearData() } recommendViewModel.refreshing = false } }, loadMore = { scope.launch(Dispatchers.IO) { recommendViewModel.loadMore() recommendViewModel.refreshing = false } } ) } 1 -> { PopularPage( state = popularGridState, windowSize = windowSize, videos = popularViewModel.popularVideoList, onClickVideo = { aid -> VideoPlayerActivity.actionStart(context = context, aid = aid) }, loading = popularViewModel.loading, refreshing = popularViewModel.refreshing, onRefresh = { scope.launch(Dispatchers.IO) { popularViewModel.resetPage() //避免刷新太快 delay(300) popularViewModel.loadMore { //clear data before set new data popularViewModel.clearData() } popularViewModel.refreshing = false } }, loadMore = { scope.launch(Dispatchers.IO) { popularViewModel.loadMore() popularViewModel.refreshing = false } } ) } } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun HomeTopAppBar( modifier: Modifier = Modifier, windowSize: WindowWidthSizeClass, avatar: String, onShowUserDialog: () -> Unit, ) { if (windowSize == WindowWidthSizeClass.Compact) { TopAppBar( modifier = modifier, title = { }, navigationIcon = {}, actions = { IconButton(onClick = onShowUserDialog) { if (avatar.isBlank()) { Icon( imageVector = Icons.Rounded.Person, contentDescription = null ) } else { Box( modifier = Modifier .clip(CircleShape) .background(Color.Gray) ) { AsyncImage( modifier = Modifier .size(36.dp), model = avatar, contentDescription = null, contentScale = ContentScale.Crop ) } } } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ) ) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/home/SearchScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen.home import android.app.Activity import android.content.res.Configuration import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExpandedFullScreenSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarValue import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberSearchBarState import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import dev.aaa1115910.biliapi.repositories.SearchTypeResult import dev.aaa1115910.bv.mobile.activities.VideoPlayerActivity import dev.aaa1115910.bv.mobile.component.preferences.items.listItemPreference import dev.aaa1115910.bv.mobile.component.preferences.preferenceGroups import dev.aaa1115910.bv.mobile.screen.home.search.SearchInputContent import dev.aaa1115910.bv.mobile.screen.home.search.SearchResultContent import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.viewmodel.search.SearchInputViewModel import dev.aaa1115910.bv.viewmodel.search.SearchResultViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun SearchScreen( modifier: Modifier = Modifier, searchInputViewModel: SearchInputViewModel = koinViewModel(), searchResultViewModel: SearchResultViewModel = koinViewModel() ) { val context = LocalContext.current val windowSizeClass = calculateWindowSizeClass(context as Activity) val windowSize = windowSizeClass.widthSizeClass val updateKeyword: (String) -> Unit = { newKeyword -> if (newKeyword != searchInputViewModel.keyword) { searchInputViewModel.keyword = newKeyword searchInputViewModel.updateSuggests() } } val onSearch: (String) -> Unit = { searchResultViewModel.keyword = it searchResultViewModel.update() searchInputViewModel.addSearchHistory(it) } val onOpenUgc: (Long) -> Unit = { aid -> VideoPlayerActivity.actionStart(context = context, aid = aid) } SearchContent( modifier = modifier, windowSize = windowSize, keywordSuggestions = searchInputViewModel.suggests, historyKeywords = searchInputViewModel.searchHistories.map { it.keyword }, matchedHistory = searchInputViewModel.matchedSearchHistories.map { it.keyword }, updateKeyword = updateKeyword, onSearch = onSearch, onOpenUgc = onOpenUgc, videoSearchResult = searchResultViewModel.videoSearchResult.videos, mediaBangumiSearchResult = searchResultViewModel.mediaBangumiSearchResult.mediaBangumis, mediaFtSearchResult = searchResultViewModel.mediaFtSearchResult.mediaFts, biliUserSearchResult = searchResultViewModel.biliUserSearchResult.biliUsers ) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun SearchContent( modifier: Modifier = Modifier, windowSize: WindowWidthSizeClass, keywordSuggestions: List = emptyList(), historyKeywords: List, matchedHistory: List, updateKeyword: (String) -> Unit = {}, onSearch: (String) -> Unit = {}, onOpenUgc: (Long) -> Unit = {}, videoSearchResult: List, mediaBangumiSearchResult: List, mediaFtSearchResult: List, biliUserSearchResult: List ) { val scope = rememberCoroutineScope() val searchBarState = rememberSearchBarState() val textFieldState = rememberTextFieldState() val navController = rememberNavController() var searchBarExpanded by remember { mutableStateOf(false) } var textFieldFocused by remember { mutableStateOf(false) } LaunchedEffect(textFieldState.text, textFieldFocused) { println("Text field state: $textFieldState") searchBarExpanded = textFieldState.text != "" && textFieldFocused updateKeyword(textFieldState.text.toString()) } val onSearchKeyword: (String) -> Unit = { onSearch(it) if (navController.currentDestination?.route != "searchResult") navController.navigate("searchResult") textFieldState.setTextAndPlaceCursorAtEnd(it) scope.launch { // 等到 searchBar 移动到顶部再收起 delay(500) searchBarState.animateToCollapsed() } } val inputField = @Composable { SearchBarDefaults.InputField( modifier = Modifier.onFocusChanged { textFieldFocused = it.isFocused }, searchBarState = searchBarState, textFieldState = textFieldState, onSearch = onSearchKeyword, placeholder = { Text(text = "在此处输入文字") }, ) } SharedTransitionLayout( modifier = modifier ) { NavHost( navController = navController, startDestination = "searchInput" ) { composable("searchInput") { SearchInputContent( windowSize = windowSize, keywordSuggestions = keywordSuggestions, keywordHistories = historyKeywords, matchedKeyworkHistories = matchedHistory, searchBarState = searchBarState, textFieldState = textFieldState, searchBarExpanded = searchBarExpanded, onSearchBarExpandedChange = { searchBarExpanded = it }, sharedTransitionScope = this@SharedTransitionLayout, animatedVisibilityScope = this@composable, inputField = inputField, onSearch = onSearchKeyword ) } composable("searchResult") { SearchResultContent( modifier = Modifier.fillMaxSize(), searchBarState = searchBarState, textFieldState = textFieldState, keywordSuggestions = keywordSuggestions, historyKeywords = historyKeywords, matchedHistory = matchedHistory, sharedTransitionScope = this@SharedTransitionLayout, animatedVisibilityScope = this@composable, inputField = inputField, videoSearchResult = videoSearchResult, mediaBangumiSearchResult = mediaBangumiSearchResult, mediaFtSearchResult = mediaFtSearchResult, biliUserSearchResult = biliUserSearchResult, onSearch = onSearchKeyword, onOpenUgc = onOpenUgc ) } } } if (windowSize == WindowWidthSizeClass.Compact) { ExpandedFullScreenSearchBar( state = searchBarState, inputField = inputField ) { SearchBarResultContent( modifier = Modifier.fillMaxSize(), keyword = textFieldState.text.toString(), recentHistory = historyKeywords, matchedHistory = matchedHistory, suggestions = keywordSuggestions, onSearch = onSearchKeyword, onDeleteHistory = {} ) } } } @Composable fun SearchBarResultContent( modifier: Modifier = Modifier, keyword: String, recentHistory: List, matchedHistory: List, suggestions: List, onSearch: (String) -> Unit, onDeleteHistory: (String) -> Unit ) { val listItemColors = ListItemDefaults.colors().copy( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, ) Surface( modifier = modifier, color = MaterialTheme.colorScheme.surfaceContainer ) { LazyColumn( modifier = Modifier, contentPadding = PaddingValues(12.dp) ) { preferenceGroups( "历史记录" to { if (keyword.isNotEmpty()) { matchedHistory.take(10).map { listItemPreference( headlineContent = { Text(text = it) }, leadingContent = { Box( modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.surface), ) { Icon( modifier = Modifier.padding(3.dp), imageVector = Icons.Default.AccessTime, contentDescription = "search history icon", ) } }, colors = listItemColors, onClick = { onSearch(it) } ) } } else { recentHistory.take(10).map { listItemPreference( headlineContent = { Text(text = it) }, leadingContent = { Box( modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.surface), ) { Icon( modifier = Modifier.padding(3.dp), imageVector = Icons.Default.AccessTime, contentDescription = "search history icon", ) } }, colors = listItemColors, onClick = { onSearch(it) } ) } } }, "搜索建议" to { if (keyword.isNotEmpty()) { suggestions.map { listItemPreference( headlineContent = { Text(text = it) }, leadingContent = { Box( modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.surface), ) { Icon( modifier = Modifier.padding(3.dp), imageVector = Icons.Default.Search, contentDescription = "search suggestion icon", ) } }, colors = listItemColors, onClick = { onSearch(it) } ) } } } ) } } } @Preview @Composable private fun SearchScreenMobilePreview() { BVMobileTheme { SearchContent( windowSize = WindowWidthSizeClass.Compact, videoSearchResult = emptyList(), mediaBangumiSearchResult = emptyList(), mediaFtSearchResult = emptyList(), biliUserSearchResult = emptyList(), historyKeywords = emptyList(), matchedHistory = emptyList() ) } } @Preview(device = "spec:width=1280dp,height=800dp,dpi=240") @Composable private fun SearchScreenTablePreview() { BVMobileTheme { SearchContent( windowSize = WindowWidthSizeClass.Expanded, videoSearchResult = emptyList(), mediaBangumiSearchResult = emptyList(), mediaFtSearchResult = emptyList(), biliUserSearchResult = emptyList(), historyKeywords = emptyList(), matchedHistory = emptyList() ) } } @OptIn(ExperimentalMaterial3Api::class) @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SearchBarResultCompatPreview() { val inputField = @Composable { SearchBarDefaults.InputField( searchBarState = rememberSearchBarState(), textFieldState = rememberTextFieldState(), onSearch = {}, placeholder = { Text(text = "在此处输入文字") }, ) } BVMobileTheme { ExpandedFullScreenSearchBar( state = rememberSearchBarState( initialValue = SearchBarValue.Expanded ), inputField = inputField ) { SearchBarResultContent( modifier = Modifier.fillMaxSize(), keyword = "123", recentHistory = listOf("123", "456", "789"), matchedHistory = listOf("123", "456", "789"), suggestions = listOf("123", "456", "789"), onSearch = {}, onDeleteHistory = {} ) } } } @OptIn(ExperimentalMaterial3Api::class) @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SearchBarResultDockedPreview() { val inputField = @Composable { SearchBarDefaults.InputField( searchBarState = rememberSearchBarState(), textFieldState = rememberTextFieldState(), onSearch = {}, placeholder = { Text(text = "在此处输入文字") }, ) } BVMobileTheme { DockedSearchBar( expanded = true, onExpandedChange = {}, inputField = inputField, ) { SearchBarResultContent( keyword = "123", recentHistory = listOf("123", "456", "789"), matchedHistory = listOf("123", "456", "789"), suggestions = listOf("123", "456", "789"), onSearch = {}, onDeleteHistory = {} ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/home/home/PopularPage.kt ================================================ package dev.aaa1115910.bv.mobile.screen.home.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.ugc.UgcItem import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.mobile.component.videocard.SmallVideoCard import dev.aaa1115910.bv.util.OnBottomReached import dev.aaa1115910.bv.util.fInfo import io.github.oshai.kotlinlogging.KotlinLogging @OptIn(ExperimentalMaterialApi::class) @Composable fun PopularPage( state: LazyGridState, windowSize: WindowWidthSizeClass, videos: List, onClickVideo: (aid: Long) -> Unit, loading: Boolean, refreshing: Boolean, onRefresh: () -> Unit, loadMore: () -> Unit ) { val logger = KotlinLogging.logger { } val pullRefreshState = rememberPullRefreshState(refreshing, { onRefresh() }) state.OnBottomReached( loading = loading ) { logger.fInfo { "on reached popular page bottom" } loadMore() } Box( modifier = Modifier .fillMaxSize() .pullRefresh(state = pullRefreshState) ) { LazyVerticalGrid( state = state, columns = GridCells.Adaptive(if (windowSize == WindowWidthSizeClass.Compact) 180.dp else 220.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp) ) { items(videos) { video -> SmallVideoCard( data = VideoCardData( avid = video.aid, title = video.title, cover = video.cover, play = video.play, danmaku = video.danmaku, upName = video.author, time = video.duration * 1000L ), onClick = { onClickVideo(video.aid) } ) } } PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/home/home/RcmdPage.kt ================================================ package dev.aaa1115910.bv.mobile.screen.home.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.ugc.UgcItem import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.mobile.component.videocard.SmallVideoCard import dev.aaa1115910.bv.util.OnBottomReached import dev.aaa1115910.bv.util.fInfo import io.github.oshai.kotlinlogging.KotlinLogging @OptIn(ExperimentalMaterialApi::class) @Composable fun RcmdPage( state: LazyGridState, windowSize: WindowWidthSizeClass, videos: List, onClickVideo: (aid: Long) -> Unit, loading: Boolean, refreshing: Boolean, onRefresh: () -> Unit, loadMore: () -> Unit ) { val logger = KotlinLogging.logger { } val pullRefreshState = rememberPullRefreshState(refreshing, { onRefresh() }) state.OnBottomReached( loading = loading ) { logger.fInfo { "on reached rcmd page bottom" } loadMore() } Box( modifier = Modifier .fillMaxSize() .pullRefresh(state = pullRefreshState) ) { LazyVerticalGrid( state = state, columns = GridCells.Adaptive(if (windowSize == WindowWidthSizeClass.Compact) 180.dp else 220.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp) ) { items(videos) { video -> SmallVideoCard( data = VideoCardData( avid = video.aid, title = video.title, cover = video.cover, play = video.play, danmaku = video.danmaku, upName = video.author, time = video.duration * 1000L ), onClick = { onClickVideo(video.aid) } ) } } PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/home/search/SearchInput.kt ================================================ package dev.aaa1115910.bv.mobile.screen.home.search import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberSearchBarState import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.mobile.screen.home.SearchBarResultContent @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun SearchInputContent( modifier: Modifier = Modifier, windowSize: WindowWidthSizeClass, keywordSuggestions: List = emptyList(), keywordHistories: List = emptyList(), matchedKeyworkHistories: List = emptyList(), searchBarState: SearchBarState = rememberSearchBarState(), textFieldState: TextFieldState = rememberTextFieldState(), searchBarExpanded: Boolean, onSearchBarExpandedChange: (Boolean) -> Unit, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, inputField: @Composable () -> Unit = {}, onSearch: (String) -> Unit ) { Scaffold( modifier = modifier, topBar = { when (windowSize) { WindowWidthSizeClass.Compact -> { } else -> { TopAppBar( title = {}, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ) ) } } }, containerColor = MaterialTheme.colorScheme.surfaceContainer ) { innerPadding -> Surface( modifier = Modifier .padding(top = innerPadding.calculateTopPadding()) .fillMaxSize(), color = MaterialTheme.colorScheme.surface, shape = if (windowSize == WindowWidthSizeClass.Compact) RoundedCornerShape(0.dp) else MaterialTheme.shapes.large, ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(32.dp) ) { Text( text = "搜索", style = MaterialTheme.typography.displaySmall ) when (windowSize) { WindowWidthSizeClass.Compact -> { with(sharedTransitionScope) { SearchBar( modifier = Modifier .sharedElement( sharedContentState = rememberSharedContentState("searchBar"), animatedVisibilityScope = animatedVisibilityScope ), state = searchBarState, inputField = inputField ) } } else -> { with(sharedTransitionScope) { DockedSearchBar( modifier = Modifier .imePadding() .sharedElement( sharedContentState = rememberSharedContentState("dockedSearchBar"), animatedVisibilityScope = animatedVisibilityScope ), inputField = inputField, expanded = searchBarExpanded, onExpandedChange = onSearchBarExpandedChange, ) { SearchBarResultContent( keyword = textFieldState.text.toString(), recentHistory = keywordHistories, matchedHistory = matchedKeyworkHistories, suggestions = keywordSuggestions, onSearch = onSearch, onDeleteHistory = {} ) } } } } } } } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/home/search/SearchResult.kt ================================================ package dev.aaa1115910.bv.mobile.screen.home.search import android.app.Activity import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ExpandedDockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarState import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.rememberSearchBarState import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.repositories.SearchType import dev.aaa1115910.biliapi.repositories.SearchTypeResult import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.mobile.component.search.UgcListItem import dev.aaa1115910.bv.mobile.component.videocard.SmallVideoCard import dev.aaa1115910.bv.mobile.screen.home.SearchBarResultContent import dev.aaa1115910.bv.util.removeHtmlTags @OptIn( ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class ) @Composable fun SearchResultContent( modifier: Modifier = Modifier, searchBarState: SearchBarState = rememberSearchBarState(), textFieldState: TextFieldState = rememberTextFieldState(), keywordSuggestions: List, historyKeywords: List, matchedHistory: List, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, inputField: @Composable () -> Unit = {}, videoSearchResult: List, mediaBangumiSearchResult: List, mediaFtSearchResult: List, biliUserSearchResult: List, onSearch: (String) -> Unit, onOpenUgc: (Long) -> Unit ) { val context = LocalContext.current val windowSize = calculateWindowSizeClass(context as Activity).widthSizeClass var searchType by remember { mutableStateOf(SearchType.Video) } Scaffold( modifier = modifier, topBar = { when (windowSize) { WindowWidthSizeClass.Compact -> { Column( modifier = Modifier .statusBarsPadding() ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { with(sharedTransitionScope) { SearchBar( modifier = Modifier .padding(vertical = 4.dp) .sharedElement( sharedContentState = rememberSharedContentState("searchBar"), animatedVisibilityScope = animatedVisibilityScope ), state = searchBarState, inputField = inputField ) } } PrimaryScrollableTabRow( selectedTabIndex = searchType.ordinal, ) { SearchType.entries.forEachIndexed { index, title -> Tab( selected = searchType.ordinal == index, onClick = { searchType = title }, text = { Text(text = title.name) }, ) } } } } else -> { Row( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainer) .statusBarsPadding() ) { with(sharedTransitionScope) { Box( modifier = Modifier .padding(vertical = 4.dp, horizontal = 36.dp) .sharedElement( sharedContentState = rememberSharedContentState("dockedSearchBar"), animatedVisibilityScope = animatedVisibilityScope ) ) { SearchBar( modifier = Modifier, state = searchBarState, inputField = inputField ) ExpandedDockedSearchBar( state = searchBarState, inputField = inputField ) { SearchBarResultContent( keyword = textFieldState.text.toString(), recentHistory = historyKeywords, matchedHistory = matchedHistory, suggestions = keywordSuggestions, onSearch = onSearch, onDeleteHistory = {} ) } } } PrimaryScrollableTabRow( modifier = Modifier .align(Alignment.Bottom), selectedTabIndex = searchType.ordinal, containerColor = MaterialTheme.colorScheme.surfaceContainer ) { SearchType.entries.forEachIndexed { index, title -> Tab( selected = searchType.ordinal == index, onClick = { searchType = title }, text = { Text(text = title.name) }, ) } } } } } }, containerColor = MaterialTheme.colorScheme.surfaceContainer ) { innerPadding -> Surface( modifier = Modifier .padding(top = innerPadding.calculateTopPadding()) .fillMaxSize(), color = MaterialTheme.colorScheme.surface, shape = if (windowSize == WindowWidthSizeClass.Compact) RoundedCornerShape(0.dp) else MaterialTheme.shapes.large, ) { when (searchType) { SearchType.Video -> VideoSearchResult( videoList = videoSearchResult, onClickVideo = onOpenUgc ) SearchType.MediaBangumi -> MediaBangumiSearchResult( mediaBangumiList = mediaBangumiSearchResult ) SearchType.MediaFt -> MediaFtSearchResult( mediaFtList = mediaFtSearchResult ) SearchType.BiliUser -> BiliUserSearchResult( biliUserList = biliUserSearchResult ) } } } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable private fun VideoSearchResult( modifier: Modifier = Modifier, videoList: List, onClickVideo: (aid: Long) -> Unit ) { val context = LocalContext.current val windowSize = calculateWindowSizeClass(context as Activity).widthSizeClass when (windowSize) { WindowWidthSizeClass.Compact -> LazyColumn( modifier = modifier ) { items(videoList) { video -> UgcListItem( data = VideoCardData( avid = video.aid, title = video.title.removeHtmlTags(), cover = video.cover, play = video.play, danmaku = video.danmaku, upName = video.author, time = video.duration * 1000L, pubTime = video.pubDate ), onClick = { onClickVideo(video.aid) } ) } } else -> LazyVerticalGrid( modifier = modifier, columns = GridCells.Adaptive(220.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp) ) { items(videoList) { video -> SmallVideoCard( data = VideoCardData( avid = video.aid, title = video.title.removeHtmlTags(), cover = video.cover, play = video.play, danmaku = video.danmaku, upName = video.author, time = video.duration * 1000L, pubTime = video.pubDate ), onClick = { onClickVideo(video.aid) } ) } } } } @Composable private fun MediaBangumiSearchResult( modifier: Modifier = Modifier, mediaBangumiList: List, ) { } @Composable private fun MediaFtSearchResult( modifier: Modifier = Modifier, mediaFtList: List, ) { } @Composable private fun BiliUserSearchResult( modifier: Modifier = Modifier, biliUserList: List, ) { } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/settings/SettingsCategories.kt ================================================ package dev.aaa1115910.bv.mobile.screen.settings import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.mobile.component.preferences.items.textPreference import dev.aaa1115910.bv.mobile.component.preferences.preferenceGroups import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsCategories( modifier: Modifier = Modifier, selectedSettings: MobileSettings?, onSelectedSettings: (MobileSettings) -> Unit, showNavBack: Boolean, onBack: () -> Unit ) { Scaffold( modifier = modifier, topBar = { LargeTopAppBar( title = { Text(text = "Settings") }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null ) }.takeIf { showNavBack } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) ) }, containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { innerPadding -> LazyColumn( modifier = Modifier.padding(innerPadding), contentPadding = PaddingValues(horizontal = 18.dp) ) { preferenceGroups( null to { listOf( MobileSettings.Play, MobileSettings.Advance ).forEach { item -> textPreference( title = item.title, summary = item.summary, onClick = { onSelectedSettings(item) }, selected = selectedSettings == item ) } }, null to { textPreference( title = MobileSettings.About.title, summary = MobileSettings.About.summary, onClick = { onSelectedSettings(MobileSettings.About) }, selected = selectedSettings == MobileSettings.About ) }, null to { textPreference( title = MobileSettings.Debug.title, summary = MobileSettings.Debug.summary, onClick = { onSelectedSettings(MobileSettings.Debug) }, selected = selectedSettings == MobileSettings.Debug ) } ) } } } @Preview @Composable private fun SettingsCategoriesPreview() { BVMobileTheme { Surface { SettingsCategories( selectedSettings = MobileSettings.Play, onSelectedSettings = {}, showNavBack = false, onBack = {}, ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/settings/SettingsDetails.kt ================================================ package dev.aaa1115910.bv.mobile.screen.settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.aaa1115910.bv.mobile.screen.settings.details.AboutContent import dev.aaa1115910.bv.mobile.screen.settings.details.AdvanceContent import dev.aaa1115910.bv.mobile.screen.settings.details.DebugContent import dev.aaa1115910.bv.mobile.screen.settings.details.PlayContent @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsDetails( modifier: Modifier = Modifier, selectedSettings: MobileSettings?, showNavBack: Boolean, onBack: () -> Unit ) { Scaffold( modifier = modifier, topBar = { LargeTopAppBar( title = { Text(text = selectedSettings?.title ?: "NaN") }, navigationIcon = { if (showNavBack) { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null ) } } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) ) }, containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { innerPadding -> Box(modifier = Modifier.padding(innerPadding)) val contentModifier = Modifier.padding(top = innerPadding.calculateTopPadding()) when (selectedSettings) { null, MobileSettings.Play -> PlayContent(modifier = contentModifier) MobileSettings.About -> AboutContent(modifier = contentModifier) MobileSettings.Debug -> DebugContent(modifier = contentModifier) MobileSettings.Advance -> AdvanceContent(modifier = contentModifier) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/settings/SettingsScreen.kt ================================================ package dev.aaa1115910.bv.mobile.screen.settings import android.app.Activity import androidx.activity.compose.BackHandler import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.VerticalDragHandle import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor import androidx.compose.material3.adaptive.layout.PaneExpansionState import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowWidthSizeClass import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun SettingsScreen() { val context = LocalContext.current val scope = rememberCoroutineScope() val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() var selectedSettings by rememberSaveable { mutableStateOf(null) } val singlePart = listOf(WindowWidthSizeClass.COMPACT, WindowWidthSizeClass.MEDIUM) .contains(currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass) BackHandler(scaffoldNavigator.canNavigateBack()) { scope.launch { scaffoldNavigator.navigateBack() } } ListDetailPaneScaffold( modifier = Modifier .background(MaterialTheme.colorScheme.surfaceContainerLow), directive = scaffoldNavigator.scaffoldDirective, value = scaffoldNavigator.scaffoldValue, listPane = { AnimatedPane( modifier = Modifier.preferredWidth(360.dp), enterTransition = fadeIn() + slideInHorizontally(), exitTransition = fadeOut() + slideOutHorizontally() ) { SettingsCategories( selectedSettings = if (singlePart) null else selectedSettings ?: MobileSettings.Play, onSelectedSettings = { selectedSettings = it scope.launch { scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) } }, showNavBack = !scaffoldNavigator.canNavigateBack(), onBack = { (context as Activity).finish() }, ) } }, detailPane = { AnimatedPane( modifier = Modifier, enterTransition = fadeIn() + slideInHorizontally { it / 2 }, exitTransition = fadeOut() + slideOutHorizontally { it / 2 } ) { SettingsDetails( selectedSettings = selectedSettings ?: MobileSettings.Play, showNavBack = scaffoldNavigator.canNavigateBack(), onBack = { scope.launch { scaffoldNavigator.navigateBack() } } ) } }, paneExpansionDragHandle = { state -> PaneExpansionDragHandle(state) }, paneExpansionState = rememberPaneExpansionState( keyProvider = scaffoldNavigator.scaffoldValue, anchors = PaneExpansionAnchors, ) ) } @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldScope.PaneExpansionDragHandle( state: PaneExpansionState = rememberPaneExpansionState() ) { val interactionSource = remember { MutableInteractionSource() } VerticalDragHandle( modifier = Modifier .paneExpansionDraggable( state, LocalMinimumInteractiveComponentSize.current, interactionSource, ), interactionSource = interactionSource ) } enum class MobileSettings( val title: String, val summary: String? = null ) { Play(title = "播放设置", summary = "画质编码、音频、循环模式"), About(title = "关于", summary = "一般不会有人点"), Advance(title = "更多设置", summary = "接口"), Debug(title = "调试", "瞅啥瞅"); } private val PaneExpansionAnchors = listOf( PaneExpansionAnchor.Offset.fromStart(360.dp), PaneExpansionAnchor.Proportion(0.5f), PaneExpansionAnchor.Offset.fromEnd(360.dp), ) ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/settings/details/AboutContent.kt ================================================ package dev.aaa1115910.bv.mobile.screen.settings.details import android.content.Intent import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import dev.aaa1115910.bv.BuildConfig import dev.aaa1115910.bv.mobile.component.preferences.items.textPreference import dev.aaa1115910.bv.mobile.component.preferences.preferenceGroup import dev.aaa1115910.bv.mobile.component.settings.UpdateDialog import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable fun AboutContent( modifier: Modifier = Modifier ) { val context = LocalContext.current var showUpdateDialog by remember { mutableStateOf(false) } LazyColumn( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(horizontal = 18.dp) ) { item { AppIcon( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp) ) } preferenceGroup { textPreference( title = "当前版本", summary = "${BuildConfig.VERSION_NAME}.${BuildConfig.BUILD_TYPE}", onClick = { showUpdateDialog = true } ) textPreference( title = "项目地址", summary = "https://github.com/aaa1115910/bv", onClick = { val url = "https://github.com/aaa1115910/bv" val intent = Intent(Intent.ACTION_VIEW, url.toUri()) context.startActivity(intent) } ) } } UpdateDialog( show = showUpdateDialog, onHideDialog = { showUpdateDialog = false } ) } @Composable private fun AppIcon(modifier: Modifier = Modifier) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { Image( modifier = Modifier .width(256.dp) .height(128.dp), painter = painterResource(id = dev.aaa1115910.bv.R.drawable.ic_launcher_foreground), contentDescription = null, contentScale = ContentScale.FillWidth ) Text( text = "Bug Video", style = MaterialTheme.typography.titleLarge, ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun AppIconPreview() { BVMobileTheme { Surface { AppIcon() } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(device = "spec:width=1280dp,height=800dp,dpi=240") @Preview( device = "spec:width=1280dp,height=800dp,dpi=240", uiMode = Configuration.UI_MODE_NIGHT_YES ) @Composable private fun AboutContentPreview() { BVMobileTheme { Surface( color = MaterialTheme.colorScheme.surfaceContainerLow ) { AboutContent( modifier = Modifier.fillMaxSize() ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/settings/details/AdvanceContent.kt ================================================ package dev.aaa1115910.bv.mobile.screen.settings.details import android.content.res.Configuration import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.bv.mobile.component.preferences.items.radioPreference import dev.aaa1115910.bv.mobile.component.preferences.preferenceGroups import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.util.PrefKeys @Composable fun AdvanceContent( modifier: Modifier = Modifier ) { LazyColumn( modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp) ) { preferenceGroups( null to { radioPreference( title = "接口偏好", prefReq = PrefKeys.prefApiTypeRequest, values = ApiType.entries.associate { it.ordinal to it.name } .toSortedMap { a, b -> a.compareTo(b) } ) } ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun AdvanceContentPreview() { BVMobileTheme { Surface( color = MaterialTheme.colorScheme.surfaceContainerLow ) { AdvanceContent( modifier = Modifier.padding(8.dp) ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/settings/details/DebugContent.kt ================================================ package dev.aaa1115910.bv.mobile.screen.settings.details import android.content.res.Configuration import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.mobile.theme.BVMobileTheme @Composable fun DebugContent( modifier: Modifier = Modifier ) { } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DebugContentPreview() { BVMobileTheme { Surface( color = MaterialTheme.colorScheme.surfaceContainerLow ) { DebugContent( modifier = Modifier.padding(8.dp) ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/screen/settings/details/PlayContent.kt ================================================ package dev.aaa1115910.bv.mobile.screen.settings.details import android.content.res.Configuration import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.mobile.component.preferences.items.radioPreference import dev.aaa1115910.bv.mobile.component.preferences.preferenceGroups import dev.aaa1115910.bv.mobile.theme.BVMobileTheme import dev.aaa1115910.bv.player.entity.Audio import dev.aaa1115910.bv.player.entity.Resolution import dev.aaa1115910.bv.player.entity.VideoCodec import dev.aaa1115910.bv.util.PrefKeys @Composable fun PlayContent( modifier: Modifier = Modifier ) { val context = LocalContext.current LazyColumn( modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp) ) { preferenceGroups( "画面" to { radioPreference( title = "默认画质", prefReq = PrefKeys.prefDefaultQualityRequest, values = Resolution.entries.associate { it.code to it.getDisplayName(context) } .toSortedMap { a, b -> a.compareTo(b) } ) radioPreference( title = "默认视频编码", prefReq = PrefKeys.prefDefaultVideoCodecRequest, values = VideoCodec.entries.associate { it.codecId to it.getDisplayName(context) } .toSortedMap { a, b -> a.compareTo(b) } ) }, "音频" to { radioPreference( title = "默认音频", prefReq = PrefKeys.prefDefaultAudioRequest, values = Audio.entries.associate { it.code to it.getDisplayName(context) } .toSortedMap { a, b -> a.compareTo(b) } ) } ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PlayContentPreview() { BVMobileTheme { Surface( color = MaterialTheme.colorScheme.surfaceContainerLow ) { PlayContent( modifier = Modifier.padding(8.dp) ) } } } ================================================ FILE: app/mobile/src/main/kotlin/dev/aaa1115910/bv/mobile/theme/Theme.kt ================================================ package dev.aaa1115910.bv.mobile.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat import com.google.accompanist.systemuicontroller.rememberSystemUiController @Composable fun BVMobileTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val context = LocalContext.current val window by lazy { (context as Activity).window } val view = LocalView.current val systemUiController = rememberSystemUiController() val useDarkIcons = !isSystemInDarkTheme() val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> darkColorScheme() else -> lightColorScheme() } if (!view.isInEditMode) { val currentWindow = (view.context as Activity).window SideEffect { (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb() WindowCompat.getInsetsController(currentWindow, view) .isAppearanceLightStatusBars = darkTheme } } SideEffect { systemUiController.setStatusBarColor(color = Color.Transparent) systemUiController.setNavigationBarColor(color = Color.Transparent) if (!view.isInEditMode) { WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = useDarkIcons } } MaterialTheme( colorScheme = colorScheme, ) { content() } } ================================================ FILE: app/mobile/src/main/res/values/strings.xml ================================================ 二维码登录 添加用户 添加成功 动态详情 我的收藏 我的追番 我的关注 历史记录 BV 用户登录 扫码登录 设置 用户空间 视频播放 ================================================ FILE: app/mobile/src/main/res/values/themes.xml ================================================